tenseleyflow/shithub / 1671b76

Browse files

S16: repo_redirects + repo_transfer_requests tables, lifecycle queries

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1671b768cc0cae5c16fd6792d8285c29f4be4622
Parents
13a5114
Tree
88d0c84

9 changed files

StatusFile+-
M internal/auth/policy/sqlc/models.go 110 0
M internal/meta/sqlc/models.go 110 0
A internal/migrationsfs/migrations/0020_repo_lifecycle.sql 93 0
A internal/repos/queries/lifecycle.sql 162 0
A internal/repos/sqlc/lifecycle.sql.go 501 0
M internal/repos/sqlc/models.go 110 0
M internal/repos/sqlc/querier.go 60 0
M internal/users/sqlc/models.go 110 0
M internal/worker/sqlc/models.go 110 0
internal/auth/policy/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
99
 	return string(ns.RepoVisibility), nil
99
 	return string(ns.RepoVisibility), nil
100
 }
100
 }
101
 
101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102
 type AuthAuditLog struct {
189
 type AuthAuditLog struct {
103
 	ID         int64
190
 	ID         int64
104
 	ActorID    pgtype.Int8
191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199
 	AddedByUserID pgtype.Int8
286
 	AddedByUserID pgtype.Int8
200
 }
287
 }
201
 
288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202
 type User struct {
312
 type User struct {
203
 	ID                int64
313
 	ID                int64
204
 	Username          string
314
 	Username          string
internal/meta/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
99
 	return string(ns.RepoVisibility), nil
99
 	return string(ns.RepoVisibility), nil
100
 }
100
 }
101
 
101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102
 type AuthAuditLog struct {
189
 type AuthAuditLog struct {
103
 	ID         int64
190
 	ID         int64
104
 	ActorID    pgtype.Int8
191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199
 	AddedByUserID pgtype.Int8
286
 	AddedByUserID pgtype.Int8
200
 }
287
 }
201
 
288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202
 type User struct {
312
 type User struct {
203
 	ID                int64
313
 	ID                int64
204
 	Username          string
314
 	Username          string
internal/migrationsfs/migrations/0020_repo_lifecycle.sqladded
@@ -0,0 +1,93 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S16 repo lifecycle.
4
+--
5
+-- Two tables, both auxiliary to repos:
6
+--
7
+--   repo_redirects          — one row per (old_owner, old_name) → repo_id
8
+--                             so /old/repo 301s to the current path.
9
+--                             Written on rename and on transfer-accept.
10
+--                             A single repo accumulates redirect rows
11
+--                             over its lifetime; the current name is
12
+--                             always on `repos.name` itself.
13
+--
14
+--   repo_transfer_requests  — pending transfer offers. The recipient
15
+--                             accepts or declines; expiration is 7
16
+--                             days; the sender can cancel.
17
+
18
+-- +goose Up
19
+
20
+CREATE TABLE repo_redirects (
21
+    old_owner_user_id  bigint,
22
+    old_owner_org_id   bigint,
23
+    old_name           citext      NOT NULL,
24
+    repo_id            bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
25
+    redirected_at      timestamptz NOT NULL DEFAULT now(),
26
+
27
+    -- Exactly one old-owner FK is set, mirroring repos.owner_xor.
28
+    CONSTRAINT repo_redirects_old_owner_xor CHECK (
29
+        (old_owner_user_id IS NOT NULL AND old_owner_org_id IS NULL)
30
+     OR (old_owner_user_id IS NULL     AND old_owner_org_id IS NOT NULL)
31
+    ),
32
+    CONSTRAINT repo_redirects_old_name_length CHECK (char_length(old_name::text) BETWEEN 1 AND 100)
33
+);
34
+
35
+-- Lookup index for /{old_owner}/{old_name} → repo_id. Two partial
36
+-- indexes mirror the unique index pattern on `repos`.
37
+CREATE UNIQUE INDEX repo_redirects_user_old_idx
38
+    ON repo_redirects (old_owner_user_id, old_name)
39
+    WHERE old_owner_user_id IS NOT NULL;
40
+
41
+CREATE UNIQUE INDEX repo_redirects_org_old_idx
42
+    ON repo_redirects (old_owner_org_id, old_name)
43
+    WHERE old_owner_org_id IS NOT NULL;
44
+
45
+
46
+CREATE TYPE transfer_principal_kind AS ENUM ('user', 'org');
47
+CREATE TYPE transfer_status         AS ENUM ('pending', 'accepted', 'declined', 'canceled', 'expired');
48
+
49
+CREATE TABLE repo_transfer_requests (
50
+    id                 bigserial   PRIMARY KEY,
51
+    repo_id            bigint      NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
52
+    from_user_id       bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
53
+    to_principal_kind  transfer_principal_kind NOT NULL,
54
+    to_principal_id    bigint      NOT NULL,
55
+    created_by         bigint      NOT NULL REFERENCES users(id) ON DELETE CASCADE,
56
+    created_at         timestamptz NOT NULL DEFAULT now(),
57
+    expires_at         timestamptz NOT NULL,
58
+    status             transfer_status NOT NULL DEFAULT 'pending',
59
+    accepted_at        timestamptz,
60
+    declined_at        timestamptz,
61
+    canceled_at        timestamptz,
62
+
63
+    -- Status invariants. We don't validate the (status, *_at) cross-
64
+    -- invariant here — the lifecycle orchestrator owns those updates
65
+    -- in transactions and is the single writer.
66
+    CONSTRAINT repo_transfer_requests_status_terminal CHECK (
67
+        (status = 'pending'  AND accepted_at IS NULL AND declined_at IS NULL AND canceled_at IS NULL) OR
68
+        (status = 'accepted' AND accepted_at IS NOT NULL) OR
69
+        (status = 'declined' AND declined_at IS NOT NULL) OR
70
+        (status = 'canceled' AND canceled_at IS NOT NULL) OR
71
+        (status = 'expired')
72
+    )
73
+);
74
+
75
+-- Recipient inbox lookup: "show me pending transfers offered to user X."
76
+CREATE INDEX repo_transfer_requests_recipient_idx
77
+    ON repo_transfer_requests (to_principal_kind, to_principal_id, status)
78
+    WHERE status = 'pending';
79
+
80
+-- Sender lookup + history.
81
+CREATE INDEX repo_transfer_requests_repo_idx
82
+    ON repo_transfer_requests (repo_id, created_at DESC);
83
+
84
+-- Expiry sweep (the worker that flips pending → expired runs over this).
85
+CREATE INDEX repo_transfer_requests_expiry_idx
86
+    ON repo_transfer_requests (expires_at)
87
+    WHERE status = 'pending';
88
+
89
+-- +goose Down
90
+DROP TABLE IF EXISTS repo_transfer_requests;
91
+DROP TYPE IF EXISTS transfer_status;
92
+DROP TYPE IF EXISTS transfer_principal_kind;
93
+DROP TABLE IF EXISTS repo_redirects;
internal/repos/queries/lifecycle.sqladded
@@ -0,0 +1,162 @@
1
+-- SPDX-License-Identifier: AGPL-3.0-or-later
2
+--
3
+-- S16 lifecycle queries. Kept in a separate file from repos.sql so the
4
+-- mainline CRUD stays easy to read.
5
+
6
+-- ─── repo mutations ────────────────────────────────────────────────────
7
+
8
+-- name: RenameRepo :exec
9
+-- Same-owner rename. The handler validates the new name shape and the
10
+-- redirect row is INSERTed in the same tx.
11
+UPDATE repos SET name = $2, updated_at = now() WHERE id = $1;
12
+
13
+-- name: TransferRepoOwner :exec
14
+-- Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
15
+-- check on the table enforces the shape. Also clears the other side
16
+-- so a user→org transfer flips both columns atomically.
17
+UPDATE repos
18
+SET owner_user_id = sqlc.narg(owner_user_id)::bigint,
19
+    owner_org_id  = sqlc.narg(owner_org_id)::bigint,
20
+    name          = $2,
21
+    updated_at    = now()
22
+WHERE id = $1;
23
+
24
+-- name: ArchiveRepo :exec
25
+UPDATE repos SET is_archived = true, archived_at = now(), updated_at = now() WHERE id = $1;
26
+
27
+-- name: UnarchiveRepo :exec
28
+UPDATE repos SET is_archived = false, archived_at = NULL, updated_at = now() WHERE id = $1;
29
+
30
+-- name: SetRepoVisibility :exec
31
+UPDATE repos SET visibility = $2, updated_at = now() WHERE id = $1;
32
+
33
+-- name: SoftDeleteRepoLifecycle :exec
34
+-- Distinct name from S11's SoftDeleteRepo so future code that wants to
35
+-- preserve the lifecycle audit-emission shape can find this one.
36
+UPDATE repos SET deleted_at = now(), updated_at = now() WHERE id = $1;
37
+
38
+-- name: RestoreRepo :exec
39
+UPDATE repos SET deleted_at = NULL, updated_at = now() WHERE id = $1;
40
+
41
+-- name: HardDeleteRepo :exec
42
+DELETE FROM repos WHERE id = $1;
43
+
44
+
45
+-- ─── redirects ─────────────────────────────────────────────────────────
46
+
47
+-- name: InsertRepoRedirect :exec
48
+-- Both old-owner FKs are nullable; pass exactly one. The CHECK
49
+-- constraint on the table enforces the xor shape.
50
+INSERT INTO repo_redirects (old_owner_user_id, old_owner_org_id, old_name, repo_id)
51
+VALUES (sqlc.narg(old_owner_user_id)::bigint, sqlc.narg(old_owner_org_id)::bigint, $1, $2)
52
+ON CONFLICT DO NOTHING;
53
+
54
+-- name: LookupRedirectByUserOwner :one
55
+-- Returns the current repo_id when (old_owner_user_id, old_name) hits
56
+-- a redirect row.
57
+SELECT repo_id FROM repo_redirects
58
+WHERE old_owner_user_id = $1 AND old_name = $2;
59
+
60
+-- name: DeleteRedirectsForRepo :exec
61
+-- Used by hard-delete: drop the redirect rows pointing at this repo
62
+-- (they would dangle once the repos row is gone; the FK ON DELETE
63
+-- CASCADE would handle it, but explicit is auditable).
64
+DELETE FROM repo_redirects WHERE repo_id = $1;
65
+
66
+
67
+-- ─── transfer requests ─────────────────────────────────────────────────
68
+
69
+-- name: InsertTransferRequest :one
70
+INSERT INTO repo_transfer_requests (
71
+    repo_id, from_user_id, to_principal_kind, to_principal_id,
72
+    created_by, expires_at
73
+) VALUES (
74
+    $1, $2, $3, $4, $5, $6
75
+)
76
+RETURNING id, repo_id, from_user_id, to_principal_kind, to_principal_id,
77
+          created_by, created_at, expires_at, status,
78
+          accepted_at, declined_at, canceled_at;
79
+
80
+-- name: GetTransferRequest :one
81
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
82
+       created_by, created_at, expires_at, status,
83
+       accepted_at, declined_at, canceled_at
84
+FROM repo_transfer_requests
85
+WHERE id = $1;
86
+
87
+-- name: ListPendingTransfersForUser :many
88
+-- Inbox view: pending offers a user can act on.
89
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
90
+       created_by, created_at, expires_at, status,
91
+       accepted_at, declined_at, canceled_at
92
+FROM repo_transfer_requests
93
+WHERE to_principal_kind = 'user' AND to_principal_id = $1 AND status = 'pending'
94
+ORDER BY created_at DESC;
95
+
96
+-- name: ListTransfersForRepo :many
97
+-- Sender / repo-settings view.
98
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
99
+       created_by, created_at, expires_at, status,
100
+       accepted_at, declined_at, canceled_at
101
+FROM repo_transfer_requests
102
+WHERE repo_id = $1
103
+ORDER BY created_at DESC;
104
+
105
+-- name: AcceptTransferRequest :exec
106
+UPDATE repo_transfer_requests
107
+SET status = 'accepted', accepted_at = now()
108
+WHERE id = $1 AND status = 'pending';
109
+
110
+-- name: DeclineTransferRequest :exec
111
+UPDATE repo_transfer_requests
112
+SET status = 'declined', declined_at = now()
113
+WHERE id = $1 AND status = 'pending';
114
+
115
+-- name: CancelTransferRequest :exec
116
+UPDATE repo_transfer_requests
117
+SET status = 'canceled', canceled_at = now()
118
+WHERE id = $1 AND status = 'pending';
119
+
120
+-- name: ExpirePendingTransfers :execrows
121
+-- Called by the periodic worker (transfers:expire) — flips pending
122
+-- offers past their expires_at to the expired terminal state.
123
+UPDATE repo_transfer_requests
124
+SET status = 'expired'
125
+WHERE status = 'pending' AND expires_at < now();
126
+
127
+
128
+-- ─── soft-delete sweep query ───────────────────────────────────────────
129
+
130
+-- name: ListRepoIDsPastSoftDeleteGrace :many
131
+-- The repo:hard_delete enqueuer queries this to find rows ready for
132
+-- destruction. The 7-day grace is hard-coded here; if we add a config
133
+-- knob later, change this to a parameter.
134
+SELECT id FROM repos
135
+WHERE deleted_at IS NOT NULL AND deleted_at < now() - interval '7 days'
136
+ORDER BY deleted_at ASC;
137
+
138
+-- name: ListSoftDeletedReposForOwner :many
139
+-- /settings/repositories/restore page lists these.
140
+SELECT id, owner_user_id, name, deleted_at
141
+FROM repos
142
+WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
143
+ORDER BY deleted_at DESC;
144
+
145
+
146
+-- ─── rename rate limit support ─────────────────────────────────────────
147
+
148
+-- name: CountRecentRedirectsForRepo :one
149
+-- Used to enforce the 5-per-30-days rename rate limit. The redirect
150
+-- row is the audit trail for renames; counting them per repo gives a
151
+-- reliable cap.
152
+SELECT count(*)::int AS recent_count
153
+FROM repo_redirects
154
+WHERE repo_id = $1 AND redirected_at > now() - interval '30 days';
155
+
156
+
157
+-- ─── fork-anchor cleanup on hard delete ────────────────────────────────
158
+
159
+-- name: OrphanForksOf :execrows
160
+-- Children pointing at this repo lose their fork-of pointer. Mirrors
161
+-- GitHub's behavior when an upstream is deleted.
162
+UPDATE repos SET fork_of_repo_id = NULL WHERE fork_of_repo_id = $1;
internal/repos/sqlc/lifecycle.sql.goadded
@@ -0,0 +1,501 @@
1
+// Code generated by sqlc. DO NOT EDIT.
2
+// versions:
3
+//   sqlc v1.31.1
4
+// source: lifecycle.sql
5
+
6
+package reposdb
7
+
8
+import (
9
+	"context"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+)
13
+
14
+const acceptTransferRequest = `-- name: AcceptTransferRequest :exec
15
+UPDATE repo_transfer_requests
16
+SET status = 'accepted', accepted_at = now()
17
+WHERE id = $1 AND status = 'pending'
18
+`
19
+
20
+func (q *Queries) AcceptTransferRequest(ctx context.Context, db DBTX, id int64) error {
21
+	_, err := db.Exec(ctx, acceptTransferRequest, id)
22
+	return err
23
+}
24
+
25
+const archiveRepo = `-- name: ArchiveRepo :exec
26
+UPDATE repos SET is_archived = true, archived_at = now(), updated_at = now() WHERE id = $1
27
+`
28
+
29
+func (q *Queries) ArchiveRepo(ctx context.Context, db DBTX, id int64) error {
30
+	_, err := db.Exec(ctx, archiveRepo, id)
31
+	return err
32
+}
33
+
34
+const cancelTransferRequest = `-- name: CancelTransferRequest :exec
35
+UPDATE repo_transfer_requests
36
+SET status = 'canceled', canceled_at = now()
37
+WHERE id = $1 AND status = 'pending'
38
+`
39
+
40
+func (q *Queries) CancelTransferRequest(ctx context.Context, db DBTX, id int64) error {
41
+	_, err := db.Exec(ctx, cancelTransferRequest, id)
42
+	return err
43
+}
44
+
45
+const countRecentRedirectsForRepo = `-- name: CountRecentRedirectsForRepo :one
46
+
47
+SELECT count(*)::int AS recent_count
48
+FROM repo_redirects
49
+WHERE repo_id = $1 AND redirected_at > now() - interval '30 days'
50
+`
51
+
52
+// ─── rename rate limit support ─────────────────────────────────────────
53
+// Used to enforce the 5-per-30-days rename rate limit. The redirect
54
+// row is the audit trail for renames; counting them per repo gives a
55
+// reliable cap.
56
+func (q *Queries) CountRecentRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) (int32, error) {
57
+	row := db.QueryRow(ctx, countRecentRedirectsForRepo, repoID)
58
+	var recent_count int32
59
+	err := row.Scan(&recent_count)
60
+	return recent_count, err
61
+}
62
+
63
+const declineTransferRequest = `-- name: DeclineTransferRequest :exec
64
+UPDATE repo_transfer_requests
65
+SET status = 'declined', declined_at = now()
66
+WHERE id = $1 AND status = 'pending'
67
+`
68
+
69
+func (q *Queries) DeclineTransferRequest(ctx context.Context, db DBTX, id int64) error {
70
+	_, err := db.Exec(ctx, declineTransferRequest, id)
71
+	return err
72
+}
73
+
74
+const deleteRedirectsForRepo = `-- name: DeleteRedirectsForRepo :exec
75
+DELETE FROM repo_redirects WHERE repo_id = $1
76
+`
77
+
78
+// Used by hard-delete: drop the redirect rows pointing at this repo
79
+// (they would dangle once the repos row is gone; the FK ON DELETE
80
+// CASCADE would handle it, but explicit is auditable).
81
+func (q *Queries) DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error {
82
+	_, err := db.Exec(ctx, deleteRedirectsForRepo, repoID)
83
+	return err
84
+}
85
+
86
+const expirePendingTransfers = `-- name: ExpirePendingTransfers :execrows
87
+UPDATE repo_transfer_requests
88
+SET status = 'expired'
89
+WHERE status = 'pending' AND expires_at < now()
90
+`
91
+
92
+// Called by the periodic worker (transfers:expire) — flips pending
93
+// offers past their expires_at to the expired terminal state.
94
+func (q *Queries) ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error) {
95
+	result, err := db.Exec(ctx, expirePendingTransfers)
96
+	if err != nil {
97
+		return 0, err
98
+	}
99
+	return result.RowsAffected(), nil
100
+}
101
+
102
+const getTransferRequest = `-- name: GetTransferRequest :one
103
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
104
+       created_by, created_at, expires_at, status,
105
+       accepted_at, declined_at, canceled_at
106
+FROM repo_transfer_requests
107
+WHERE id = $1
108
+`
109
+
110
+func (q *Queries) GetTransferRequest(ctx context.Context, db DBTX, id int64) (RepoTransferRequest, error) {
111
+	row := db.QueryRow(ctx, getTransferRequest, id)
112
+	var i RepoTransferRequest
113
+	err := row.Scan(
114
+		&i.ID,
115
+		&i.RepoID,
116
+		&i.FromUserID,
117
+		&i.ToPrincipalKind,
118
+		&i.ToPrincipalID,
119
+		&i.CreatedBy,
120
+		&i.CreatedAt,
121
+		&i.ExpiresAt,
122
+		&i.Status,
123
+		&i.AcceptedAt,
124
+		&i.DeclinedAt,
125
+		&i.CanceledAt,
126
+	)
127
+	return i, err
128
+}
129
+
130
+const hardDeleteRepo = `-- name: HardDeleteRepo :exec
131
+DELETE FROM repos WHERE id = $1
132
+`
133
+
134
+func (q *Queries) HardDeleteRepo(ctx context.Context, db DBTX, id int64) error {
135
+	_, err := db.Exec(ctx, hardDeleteRepo, id)
136
+	return err
137
+}
138
+
139
+const insertRepoRedirect = `-- name: InsertRepoRedirect :exec
140
+
141
+INSERT INTO repo_redirects (old_owner_user_id, old_owner_org_id, old_name, repo_id)
142
+VALUES ($3::bigint, $4::bigint, $1, $2)
143
+ON CONFLICT DO NOTHING
144
+`
145
+
146
+type InsertRepoRedirectParams struct {
147
+	OldName        string
148
+	RepoID         int64
149
+	OldOwnerUserID pgtype.Int8
150
+	OldOwnerOrgID  pgtype.Int8
151
+}
152
+
153
+// ─── redirects ─────────────────────────────────────────────────────────
154
+// Both old-owner FKs are nullable; pass exactly one. The CHECK
155
+// constraint on the table enforces the xor shape.
156
+func (q *Queries) InsertRepoRedirect(ctx context.Context, db DBTX, arg InsertRepoRedirectParams) error {
157
+	_, err := db.Exec(ctx, insertRepoRedirect,
158
+		arg.OldName,
159
+		arg.RepoID,
160
+		arg.OldOwnerUserID,
161
+		arg.OldOwnerOrgID,
162
+	)
163
+	return err
164
+}
165
+
166
+const insertTransferRequest = `-- name: InsertTransferRequest :one
167
+
168
+INSERT INTO repo_transfer_requests (
169
+    repo_id, from_user_id, to_principal_kind, to_principal_id,
170
+    created_by, expires_at
171
+) VALUES (
172
+    $1, $2, $3, $4, $5, $6
173
+)
174
+RETURNING id, repo_id, from_user_id, to_principal_kind, to_principal_id,
175
+          created_by, created_at, expires_at, status,
176
+          accepted_at, declined_at, canceled_at
177
+`
178
+
179
+type InsertTransferRequestParams struct {
180
+	RepoID          int64
181
+	FromUserID      int64
182
+	ToPrincipalKind TransferPrincipalKind
183
+	ToPrincipalID   int64
184
+	CreatedBy       int64
185
+	ExpiresAt       pgtype.Timestamptz
186
+}
187
+
188
+// ─── transfer requests ─────────────────────────────────────────────────
189
+func (q *Queries) InsertTransferRequest(ctx context.Context, db DBTX, arg InsertTransferRequestParams) (RepoTransferRequest, error) {
190
+	row := db.QueryRow(ctx, insertTransferRequest,
191
+		arg.RepoID,
192
+		arg.FromUserID,
193
+		arg.ToPrincipalKind,
194
+		arg.ToPrincipalID,
195
+		arg.CreatedBy,
196
+		arg.ExpiresAt,
197
+	)
198
+	var i RepoTransferRequest
199
+	err := row.Scan(
200
+		&i.ID,
201
+		&i.RepoID,
202
+		&i.FromUserID,
203
+		&i.ToPrincipalKind,
204
+		&i.ToPrincipalID,
205
+		&i.CreatedBy,
206
+		&i.CreatedAt,
207
+		&i.ExpiresAt,
208
+		&i.Status,
209
+		&i.AcceptedAt,
210
+		&i.DeclinedAt,
211
+		&i.CanceledAt,
212
+	)
213
+	return i, err
214
+}
215
+
216
+const listPendingTransfersForUser = `-- name: ListPendingTransfersForUser :many
217
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
218
+       created_by, created_at, expires_at, status,
219
+       accepted_at, declined_at, canceled_at
220
+FROM repo_transfer_requests
221
+WHERE to_principal_kind = 'user' AND to_principal_id = $1 AND status = 'pending'
222
+ORDER BY created_at DESC
223
+`
224
+
225
+// Inbox view: pending offers a user can act on.
226
+func (q *Queries) ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error) {
227
+	rows, err := db.Query(ctx, listPendingTransfersForUser, toPrincipalID)
228
+	if err != nil {
229
+		return nil, err
230
+	}
231
+	defer rows.Close()
232
+	items := []RepoTransferRequest{}
233
+	for rows.Next() {
234
+		var i RepoTransferRequest
235
+		if err := rows.Scan(
236
+			&i.ID,
237
+			&i.RepoID,
238
+			&i.FromUserID,
239
+			&i.ToPrincipalKind,
240
+			&i.ToPrincipalID,
241
+			&i.CreatedBy,
242
+			&i.CreatedAt,
243
+			&i.ExpiresAt,
244
+			&i.Status,
245
+			&i.AcceptedAt,
246
+			&i.DeclinedAt,
247
+			&i.CanceledAt,
248
+		); err != nil {
249
+			return nil, err
250
+		}
251
+		items = append(items, i)
252
+	}
253
+	if err := rows.Err(); err != nil {
254
+		return nil, err
255
+	}
256
+	return items, nil
257
+}
258
+
259
+const listRepoIDsPastSoftDeleteGrace = `-- name: ListRepoIDsPastSoftDeleteGrace :many
260
+
261
+SELECT id FROM repos
262
+WHERE deleted_at IS NOT NULL AND deleted_at < now() - interval '7 days'
263
+ORDER BY deleted_at ASC
264
+`
265
+
266
+// ─── soft-delete sweep query ───────────────────────────────────────────
267
+// The repo:hard_delete enqueuer queries this to find rows ready for
268
+// destruction. The 7-day grace is hard-coded here; if we add a config
269
+// knob later, change this to a parameter.
270
+func (q *Queries) ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) {
271
+	rows, err := db.Query(ctx, listRepoIDsPastSoftDeleteGrace)
272
+	if err != nil {
273
+		return nil, err
274
+	}
275
+	defer rows.Close()
276
+	items := []int64{}
277
+	for rows.Next() {
278
+		var id int64
279
+		if err := rows.Scan(&id); err != nil {
280
+			return nil, err
281
+		}
282
+		items = append(items, id)
283
+	}
284
+	if err := rows.Err(); err != nil {
285
+		return nil, err
286
+	}
287
+	return items, nil
288
+}
289
+
290
+const listSoftDeletedReposForOwner = `-- name: ListSoftDeletedReposForOwner :many
291
+SELECT id, owner_user_id, name, deleted_at
292
+FROM repos
293
+WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
294
+ORDER BY deleted_at DESC
295
+`
296
+
297
+type ListSoftDeletedReposForOwnerRow struct {
298
+	ID          int64
299
+	OwnerUserID pgtype.Int8
300
+	Name        string
301
+	DeletedAt   pgtype.Timestamptz
302
+}
303
+
304
+// /settings/repositories/restore page lists these.
305
+func (q *Queries) ListSoftDeletedReposForOwner(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListSoftDeletedReposForOwnerRow, error) {
306
+	rows, err := db.Query(ctx, listSoftDeletedReposForOwner, ownerUserID)
307
+	if err != nil {
308
+		return nil, err
309
+	}
310
+	defer rows.Close()
311
+	items := []ListSoftDeletedReposForOwnerRow{}
312
+	for rows.Next() {
313
+		var i ListSoftDeletedReposForOwnerRow
314
+		if err := rows.Scan(
315
+			&i.ID,
316
+			&i.OwnerUserID,
317
+			&i.Name,
318
+			&i.DeletedAt,
319
+		); err != nil {
320
+			return nil, err
321
+		}
322
+		items = append(items, i)
323
+	}
324
+	if err := rows.Err(); err != nil {
325
+		return nil, err
326
+	}
327
+	return items, nil
328
+}
329
+
330
+const listTransfersForRepo = `-- name: ListTransfersForRepo :many
331
+SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
332
+       created_by, created_at, expires_at, status,
333
+       accepted_at, declined_at, canceled_at
334
+FROM repo_transfer_requests
335
+WHERE repo_id = $1
336
+ORDER BY created_at DESC
337
+`
338
+
339
+// Sender / repo-settings view.
340
+func (q *Queries) ListTransfersForRepo(ctx context.Context, db DBTX, repoID int64) ([]RepoTransferRequest, error) {
341
+	rows, err := db.Query(ctx, listTransfersForRepo, repoID)
342
+	if err != nil {
343
+		return nil, err
344
+	}
345
+	defer rows.Close()
346
+	items := []RepoTransferRequest{}
347
+	for rows.Next() {
348
+		var i RepoTransferRequest
349
+		if err := rows.Scan(
350
+			&i.ID,
351
+			&i.RepoID,
352
+			&i.FromUserID,
353
+			&i.ToPrincipalKind,
354
+			&i.ToPrincipalID,
355
+			&i.CreatedBy,
356
+			&i.CreatedAt,
357
+			&i.ExpiresAt,
358
+			&i.Status,
359
+			&i.AcceptedAt,
360
+			&i.DeclinedAt,
361
+			&i.CanceledAt,
362
+		); err != nil {
363
+			return nil, err
364
+		}
365
+		items = append(items, i)
366
+	}
367
+	if err := rows.Err(); err != nil {
368
+		return nil, err
369
+	}
370
+	return items, nil
371
+}
372
+
373
+const lookupRedirectByUserOwner = `-- name: LookupRedirectByUserOwner :one
374
+SELECT repo_id FROM repo_redirects
375
+WHERE old_owner_user_id = $1 AND old_name = $2
376
+`
377
+
378
+type LookupRedirectByUserOwnerParams struct {
379
+	OldOwnerUserID pgtype.Int8
380
+	OldName        string
381
+}
382
+
383
+// Returns the current repo_id when (old_owner_user_id, old_name) hits
384
+// a redirect row.
385
+func (q *Queries) LookupRedirectByUserOwner(ctx context.Context, db DBTX, arg LookupRedirectByUserOwnerParams) (int64, error) {
386
+	row := db.QueryRow(ctx, lookupRedirectByUserOwner, arg.OldOwnerUserID, arg.OldName)
387
+	var repo_id int64
388
+	err := row.Scan(&repo_id)
389
+	return repo_id, err
390
+}
391
+
392
+const orphanForksOf = `-- name: OrphanForksOf :execrows
393
+
394
+UPDATE repos SET fork_of_repo_id = NULL WHERE fork_of_repo_id = $1
395
+`
396
+
397
+// ─── fork-anchor cleanup on hard delete ────────────────────────────────
398
+// Children pointing at this repo lose their fork-of pointer. Mirrors
399
+// GitHub's behavior when an upstream is deleted.
400
+func (q *Queries) OrphanForksOf(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) (int64, error) {
401
+	result, err := db.Exec(ctx, orphanForksOf, forkOfRepoID)
402
+	if err != nil {
403
+		return 0, err
404
+	}
405
+	return result.RowsAffected(), nil
406
+}
407
+
408
+const renameRepo = `-- name: RenameRepo :exec
409
+
410
+
411
+UPDATE repos SET name = $2, updated_at = now() WHERE id = $1
412
+`
413
+
414
+type RenameRepoParams struct {
415
+	ID   int64
416
+	Name string
417
+}
418
+
419
+// SPDX-License-Identifier: AGPL-3.0-or-later
420
+//
421
+// S16 lifecycle queries. Kept in a separate file from repos.sql so the
422
+// mainline CRUD stays easy to read.
423
+// ─── repo mutations ────────────────────────────────────────────────────
424
+// Same-owner rename. The handler validates the new name shape and the
425
+// redirect row is INSERTed in the same tx.
426
+func (q *Queries) RenameRepo(ctx context.Context, db DBTX, arg RenameRepoParams) error {
427
+	_, err := db.Exec(ctx, renameRepo, arg.ID, arg.Name)
428
+	return err
429
+}
430
+
431
+const restoreRepo = `-- name: RestoreRepo :exec
432
+UPDATE repos SET deleted_at = NULL, updated_at = now() WHERE id = $1
433
+`
434
+
435
+func (q *Queries) RestoreRepo(ctx context.Context, db DBTX, id int64) error {
436
+	_, err := db.Exec(ctx, restoreRepo, id)
437
+	return err
438
+}
439
+
440
+const setRepoVisibility = `-- name: SetRepoVisibility :exec
441
+UPDATE repos SET visibility = $2, updated_at = now() WHERE id = $1
442
+`
443
+
444
+type SetRepoVisibilityParams struct {
445
+	ID         int64
446
+	Visibility RepoVisibility
447
+}
448
+
449
+func (q *Queries) SetRepoVisibility(ctx context.Context, db DBTX, arg SetRepoVisibilityParams) error {
450
+	_, err := db.Exec(ctx, setRepoVisibility, arg.ID, arg.Visibility)
451
+	return err
452
+}
453
+
454
+const softDeleteRepoLifecycle = `-- name: SoftDeleteRepoLifecycle :exec
455
+UPDATE repos SET deleted_at = now(), updated_at = now() WHERE id = $1
456
+`
457
+
458
+// Distinct name from S11's SoftDeleteRepo so future code that wants to
459
+// preserve the lifecycle audit-emission shape can find this one.
460
+func (q *Queries) SoftDeleteRepoLifecycle(ctx context.Context, db DBTX, id int64) error {
461
+	_, err := db.Exec(ctx, softDeleteRepoLifecycle, id)
462
+	return err
463
+}
464
+
465
+const transferRepoOwner = `-- name: TransferRepoOwner :exec
466
+UPDATE repos
467
+SET owner_user_id = $3::bigint,
468
+    owner_org_id  = $4::bigint,
469
+    name          = $2,
470
+    updated_at    = now()
471
+WHERE id = $1
472
+`
473
+
474
+type TransferRepoOwnerParams struct {
475
+	ID          int64
476
+	Name        string
477
+	OwnerUserID pgtype.Int8
478
+	OwnerOrgID  pgtype.Int8
479
+}
480
+
481
+// Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
482
+// check on the table enforces the shape. Also clears the other side
483
+// so a user→org transfer flips both columns atomically.
484
+func (q *Queries) TransferRepoOwner(ctx context.Context, db DBTX, arg TransferRepoOwnerParams) error {
485
+	_, err := db.Exec(ctx, transferRepoOwner,
486
+		arg.ID,
487
+		arg.Name,
488
+		arg.OwnerUserID,
489
+		arg.OwnerOrgID,
490
+	)
491
+	return err
492
+}
493
+
494
+const unarchiveRepo = `-- name: UnarchiveRepo :exec
495
+UPDATE repos SET is_archived = false, archived_at = NULL, updated_at = now() WHERE id = $1
496
+`
497
+
498
+func (q *Queries) UnarchiveRepo(ctx context.Context, db DBTX, id int64) error {
499
+	_, err := db.Exec(ctx, unarchiveRepo, id)
500
+	return err
501
+}
internal/repos/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
99
 	return string(ns.RepoVisibility), nil
99
 	return string(ns.RepoVisibility), nil
100
 }
100
 }
101
 
101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102
 type AuthAuditLog struct {
189
 type AuthAuditLog struct {
103
 	ID         int64
190
 	ID         int64
104
 	ActorID    pgtype.Int8
191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199
 	AddedByUserID pgtype.Int8
286
 	AddedByUserID pgtype.Int8
200
 }
287
 }
201
 
288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202
 type User struct {
312
 type User struct {
203
 	ID                int64
313
 	ID                int64
204
 	Username          string
314
 	Username          string
internal/repos/sqlc/querier.gomodified
@@ -11,21 +11,81 @@ import (
11
 )
11
 )
12
 
12
 
13
 type Querier interface {
13
 type Querier interface {
14
+	AcceptTransferRequest(ctx context.Context, db DBTX, id int64) error
15
+	ArchiveRepo(ctx context.Context, db DBTX, id int64) error
16
+	CancelTransferRequest(ctx context.Context, db DBTX, id int64) error
17
+	// ─── rename rate limit support ─────────────────────────────────────────
18
+	// Used to enforce the 5-per-30-days rename rate limit. The redirect
19
+	// row is the audit trail for renames; counting them per repo gives a
20
+	// reliable cap.
21
+	CountRecentRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) (int32, error)
14
 	CountReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) (int64, error)
22
 	CountReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) (int64, error)
15
 	// SPDX-License-Identifier: AGPL-3.0-or-later
23
 	// SPDX-License-Identifier: AGPL-3.0-or-later
16
 	CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams) (Repo, error)
24
 	CreateRepo(ctx context.Context, db DBTX, arg CreateRepoParams) (Repo, error)
25
+	DeclineTransferRequest(ctx context.Context, db DBTX, id int64) error
26
+	// Used by hard-delete: drop the redirect rows pointing at this repo
27
+	// (they would dangle once the repos row is gone; the FK ON DELETE
28
+	// CASCADE would handle it, but explicit is auditable).
29
+	DeleteRedirectsForRepo(ctx context.Context, db DBTX, repoID int64) error
17
 	ExistsRepoForOwnerUser(ctx context.Context, db DBTX, arg ExistsRepoForOwnerUserParams) (bool, error)
30
 	ExistsRepoForOwnerUser(ctx context.Context, db DBTX, arg ExistsRepoForOwnerUserParams) (bool, error)
31
+	// Called by the periodic worker (transfers:expire) — flips pending
32
+	// offers past their expires_at to the expired terminal state.
33
+	ExpirePendingTransfers(ctx context.Context, db DBTX) (int64, error)
18
 	GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, error)
34
 	GetRepoByID(ctx context.Context, db DBTX, id int64) (Repo, error)
19
 	GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerUserAndNameParams) (Repo, error)
35
 	GetRepoByOwnerUserAndName(ctx context.Context, db DBTX, arg GetRepoByOwnerUserAndNameParams) (Repo, error)
20
 	// Returns the owner_username for a repo. Used by size-recalc and other
36
 	// Returns the owner_username for a repo. Used by size-recalc and other
21
 	// jobs that need to derive the bare-repo on-disk path without round-
37
 	// jobs that need to derive the bare-repo on-disk path without round-
22
 	// tripping through the full user row.
38
 	// tripping through the full user row.
23
 	GetRepoOwnerUsernameByID(ctx context.Context, db DBTX, id int64) (GetRepoOwnerUsernameByIDRow, error)
39
 	GetRepoOwnerUsernameByID(ctx context.Context, db DBTX, id int64) (GetRepoOwnerUsernameByIDRow, error)
40
+	GetTransferRequest(ctx context.Context, db DBTX, id int64) (RepoTransferRequest, error)
41
+	HardDeleteRepo(ctx context.Context, db DBTX, id int64) error
42
+	// ─── redirects ─────────────────────────────────────────────────────────
43
+	// Both old-owner FKs are nullable; pass exactly one. The CHECK
44
+	// constraint on the table enforces the xor shape.
45
+	InsertRepoRedirect(ctx context.Context, db DBTX, arg InsertRepoRedirectParams) error
46
+	// ─── transfer requests ─────────────────────────────────────────────────
47
+	InsertTransferRequest(ctx context.Context, db DBTX, arg InsertTransferRequestParams) (RepoTransferRequest, error)
24
 	// Used by `shithubd hooks reinstall --all` to enumerate every active
48
 	// Used by `shithubd hooks reinstall --all` to enumerate every active
25
 	// bare repo on disk and re-link its hooks.
49
 	// bare repo on disk and re-link its hooks.
26
 	ListAllRepoFullNames(ctx context.Context, db DBTX) ([]ListAllRepoFullNamesRow, error)
50
 	ListAllRepoFullNames(ctx context.Context, db DBTX) ([]ListAllRepoFullNamesRow, error)
51
+	// Inbox view: pending offers a user can act on.
52
+	ListPendingTransfersForUser(ctx context.Context, db DBTX, toPrincipalID int64) ([]RepoTransferRequest, error)
53
+	// ─── soft-delete sweep query ───────────────────────────────────────────
54
+	// The repo:hard_delete enqueuer queries this to find rows ready for
55
+	// destruction. The 7-day grace is hard-coded here; if we add a config
56
+	// knob later, change this to a parameter.
57
+	ListRepoIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error)
27
 	ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]Repo, error)
58
 	ListReposForOwnerUser(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]Repo, error)
59
+	// /settings/repositories/restore page lists these.
60
+	ListSoftDeletedReposForOwner(ctx context.Context, db DBTX, ownerUserID pgtype.Int8) ([]ListSoftDeletedReposForOwnerRow, error)
61
+	// Sender / repo-settings view.
62
+	ListTransfersForRepo(ctx context.Context, db DBTX, repoID int64) ([]RepoTransferRequest, error)
63
+	// Returns the current repo_id when (old_owner_user_id, old_name) hits
64
+	// a redirect row.
65
+	LookupRedirectByUserOwner(ctx context.Context, db DBTX, arg LookupRedirectByUserOwnerParams) (int64, error)
66
+	// ─── fork-anchor cleanup on hard delete ────────────────────────────────
67
+	// Children pointing at this repo lose their fork-of pointer. Mirrors
68
+	// GitHub's behavior when an upstream is deleted.
69
+	OrphanForksOf(ctx context.Context, db DBTX, forkOfRepoID pgtype.Int8) (int64, error)
70
+	// SPDX-License-Identifier: AGPL-3.0-or-later
71
+	//
72
+	// S16 lifecycle queries. Kept in a separate file from repos.sql so the
73
+	// mainline CRUD stays easy to read.
74
+	// ─── repo mutations ────────────────────────────────────────────────────
75
+	// Same-owner rename. The handler validates the new name shape and the
76
+	// redirect row is INSERTed in the same tx.
77
+	RenameRepo(ctx context.Context, db DBTX, arg RenameRepoParams) error
78
+	RestoreRepo(ctx context.Context, db DBTX, id int64) error
79
+	SetRepoVisibility(ctx context.Context, db DBTX, arg SetRepoVisibilityParams) error
28
 	SoftDeleteRepo(ctx context.Context, db DBTX, id int64) error
80
 	SoftDeleteRepo(ctx context.Context, db DBTX, id int64) error
81
+	// Distinct name from S11's SoftDeleteRepo so future code that wants to
82
+	// preserve the lifecycle audit-emission shape can find this one.
83
+	SoftDeleteRepoLifecycle(ctx context.Context, db DBTX, id int64) error
84
+	// Sets owner_user_id (or owner_org_id post-S31) on accept. The xor
85
+	// check on the table enforces the shape. Also clears the other side
86
+	// so a user→org transfer flips both columns atomically.
87
+	TransferRepoOwner(ctx context.Context, db DBTX, arg TransferRepoOwnerParams) error
88
+	UnarchiveRepo(ctx context.Context, db DBTX, id int64) error
29
 	// Set when push:process detects a commit on the repo's default branch.
89
 	// Set when push:process detects a commit on the repo's default branch.
30
 	// Pass NULL to clear (e.g. when the branch is force-deleted in a future
90
 	// Pass NULL to clear (e.g. when the branch is force-deleted in a future
31
 	// sprint). The repo home view reads this to decide between empty and
91
 	// sprint). The repo home view reads this to decide between empty and
internal/users/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
99
 	return string(ns.RepoVisibility), nil
99
 	return string(ns.RepoVisibility), nil
100
 }
100
 }
101
 
101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102
 type AuthAuditLog struct {
189
 type AuthAuditLog struct {
103
 	ID         int64
190
 	ID         int64
104
 	ActorID    pgtype.Int8
191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199
 	AddedByUserID pgtype.Int8
286
 	AddedByUserID pgtype.Int8
200
 }
287
 }
201
 
288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202
 type User struct {
312
 type User struct {
203
 	ID                int64
313
 	ID                int64
204
 	Username          string
314
 	Username          string
internal/worker/sqlc/models.gomodified
@@ -99,6 +99,93 @@ func (ns NullRepoVisibility) Value() (driver.Value, error) {
99
 	return string(ns.RepoVisibility), nil
99
 	return string(ns.RepoVisibility), nil
100
 }
100
 }
101
 
101
 
102
+type TransferPrincipalKind string
103
+
104
+const (
105
+	TransferPrincipalKindUser TransferPrincipalKind = "user"
106
+	TransferPrincipalKindOrg  TransferPrincipalKind = "org"
107
+)
108
+
109
+func (e *TransferPrincipalKind) Scan(src interface{}) error {
110
+	switch s := src.(type) {
111
+	case []byte:
112
+		*e = TransferPrincipalKind(s)
113
+	case string:
114
+		*e = TransferPrincipalKind(s)
115
+	default:
116
+		return fmt.Errorf("unsupported scan type for TransferPrincipalKind: %T", src)
117
+	}
118
+	return nil
119
+}
120
+
121
+type NullTransferPrincipalKind struct {
122
+	TransferPrincipalKind TransferPrincipalKind
123
+	Valid                 bool // Valid is true if TransferPrincipalKind is not NULL
124
+}
125
+
126
+// Scan implements the Scanner interface.
127
+func (ns *NullTransferPrincipalKind) Scan(value interface{}) error {
128
+	if value == nil {
129
+		ns.TransferPrincipalKind, ns.Valid = "", false
130
+		return nil
131
+	}
132
+	ns.Valid = true
133
+	return ns.TransferPrincipalKind.Scan(value)
134
+}
135
+
136
+// Value implements the driver Valuer interface.
137
+func (ns NullTransferPrincipalKind) Value() (driver.Value, error) {
138
+	if !ns.Valid {
139
+		return nil, nil
140
+	}
141
+	return string(ns.TransferPrincipalKind), nil
142
+}
143
+
144
+type TransferStatus string
145
+
146
+const (
147
+	TransferStatusPending  TransferStatus = "pending"
148
+	TransferStatusAccepted TransferStatus = "accepted"
149
+	TransferStatusDeclined TransferStatus = "declined"
150
+	TransferStatusCanceled TransferStatus = "canceled"
151
+	TransferStatusExpired  TransferStatus = "expired"
152
+)
153
+
154
+func (e *TransferStatus) Scan(src interface{}) error {
155
+	switch s := src.(type) {
156
+	case []byte:
157
+		*e = TransferStatus(s)
158
+	case string:
159
+		*e = TransferStatus(s)
160
+	default:
161
+		return fmt.Errorf("unsupported scan type for TransferStatus: %T", src)
162
+	}
163
+	return nil
164
+}
165
+
166
+type NullTransferStatus struct {
167
+	TransferStatus TransferStatus
168
+	Valid          bool // Valid is true if TransferStatus is not NULL
169
+}
170
+
171
+// Scan implements the Scanner interface.
172
+func (ns *NullTransferStatus) Scan(value interface{}) error {
173
+	if value == nil {
174
+		ns.TransferStatus, ns.Valid = "", false
175
+		return nil
176
+	}
177
+	ns.Valid = true
178
+	return ns.TransferStatus.Scan(value)
179
+}
180
+
181
+// Value implements the driver Valuer interface.
182
+func (ns NullTransferStatus) Value() (driver.Value, error) {
183
+	if !ns.Valid {
184
+		return nil, nil
185
+	}
186
+	return string(ns.TransferStatus), nil
187
+}
188
+
102
 type AuthAuditLog struct {
189
 type AuthAuditLog struct {
103
 	ID         int64
190
 	ID         int64
104
 	ActorID    pgtype.Int8
191
 	ActorID    pgtype.Int8
@@ -199,6 +286,29 @@ type RepoCollaborator struct {
199
 	AddedByUserID pgtype.Int8
286
 	AddedByUserID pgtype.Int8
200
 }
287
 }
201
 
288
 
289
+type RepoRedirect struct {
290
+	OldOwnerUserID pgtype.Int8
291
+	OldOwnerOrgID  pgtype.Int8
292
+	OldName        string
293
+	RepoID         int64
294
+	RedirectedAt   pgtype.Timestamptz
295
+}
296
+
297
+type RepoTransferRequest struct {
298
+	ID              int64
299
+	RepoID          int64
300
+	FromUserID      int64
301
+	ToPrincipalKind TransferPrincipalKind
302
+	ToPrincipalID   int64
303
+	CreatedBy       int64
304
+	CreatedAt       pgtype.Timestamptz
305
+	ExpiresAt       pgtype.Timestamptz
306
+	Status          TransferStatus
307
+	AcceptedAt      pgtype.Timestamptz
308
+	DeclinedAt      pgtype.Timestamptz
309
+	CanceledAt      pgtype.Timestamptz
310
+}
311
+
202
 type User struct {
312
 type User struct {
203
 	ID                int64
313
 	ID                int64
204
 	Username          string
314
 	Username          string