tenseleyflow/shithub / 3d70843

Browse files

S16: lifecycle package — rename, transfer, archive, visibility, soft/hard delete

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
3d7084300599add2e0c0817435506b53ed78fe7a
Parents
1671b76
Tree
bc4377b

10 changed files

StatusFile+-
M internal/auth/audit/audit.go 13 1
A internal/repos/lifecycle/archive.go 54 0
A internal/repos/lifecycle/errs.go 11 0
A internal/repos/lifecycle/hard_delete.go 121 0
A internal/repos/lifecycle/lifecycle.go 76 0
A internal/repos/lifecycle/lifecycle_test.go 334 0
A internal/repos/lifecycle/rename.go 138 0
A internal/repos/lifecycle/soft_delete.go 70 0
A internal/repos/lifecycle/transfer.go 274 0
A internal/repos/lifecycle/visibility.go 51 0
internal/auth/audit/audit.gomodified
@@ -47,7 +47,19 @@ const (
4747
 	ActionUsernameChanged      Action = "username_changed"
4848
 	ActionAccountDeleted       Action = "account_deleted"
4949
 	ActionAccountRestored      Action = "account_restored"
50
-	ActionRepoCreated          Action = "repo_created"
50
+	ActionRepoCreated            Action = "repo_created"
51
+	ActionRepoRenamed            Action = "repo_renamed"
52
+	ActionRepoArchived           Action = "repo_archived"
53
+	ActionRepoUnarchived         Action = "repo_unarchived"
54
+	ActionRepoVisibilityChanged  Action = "repo_visibility_changed"
55
+	ActionRepoSoftDeleted        Action = "repo_soft_deleted"
56
+	ActionRepoRestored           Action = "repo_restored"
57
+	ActionRepoHardDeleted        Action = "repo_hard_deleted"
58
+	ActionRepoTransferRequested  Action = "repo_transfer_requested"
59
+	ActionRepoTransferAccepted   Action = "repo_transfer_accepted"
60
+	ActionRepoTransferDeclined   Action = "repo_transfer_declined"
61
+	ActionRepoTransferCanceled   Action = "repo_transfer_canceled"
62
+	ActionRepoTransferExpired    Action = "repo_transfer_expired"
5163
 )
5264
 
5365
 // Target is a typed target-type constant.
internal/repos/lifecycle/archive.goadded
@@ -0,0 +1,54 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
10
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
11
+)
12
+
13
+// Archive sets is_archived=true on the repo. Idempotent in spirit — a
14
+// double-archive returns ErrAlreadyArchived so handlers can surface a
15
+// "this repo is already archived" friendly message without having to
16
+// inspect state up front.
17
+func Archive(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
18
+	rq := reposdb.New()
19
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
20
+	if err != nil {
21
+		return fmt.Errorf("load repo: %w", err)
22
+	}
23
+	if repo.IsArchived {
24
+		return ErrAlreadyArchived
25
+	}
26
+	if err := rq.ArchiveRepo(ctx, deps.Pool, repoID); err != nil {
27
+		return fmt.Errorf("archive: %w", err)
28
+	}
29
+	if deps.Audit != nil {
30
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
31
+			audit.ActionRepoArchived, audit.TargetRepo, repoID, nil)
32
+	}
33
+	return nil
34
+}
35
+
36
+// Unarchive clears is_archived. Same idempotency contract as Archive.
37
+func Unarchive(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
38
+	rq := reposdb.New()
39
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
40
+	if err != nil {
41
+		return fmt.Errorf("load repo: %w", err)
42
+	}
43
+	if !repo.IsArchived {
44
+		return ErrNotArchived
45
+	}
46
+	if err := rq.UnarchiveRepo(ctx, deps.Pool, repoID); err != nil {
47
+		return fmt.Errorf("unarchive: %w", err)
48
+	}
49
+	if deps.Audit != nil {
50
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
51
+			audit.ActionRepoUnarchived, audit.TargetRepo, repoID, nil)
52
+	}
53
+	return nil
54
+}
internal/repos/lifecycle/errs.goadded
@@ -0,0 +1,11 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import "errors"
6
+
7
+// errAs is a tiny wrapper so the package's call sites read as a single
8
+// expression. errors.As can't be used inline in a type-switch context.
9
+func errAs(err error, target any) bool {
10
+	return errors.As(err, target)
11
+}
internal/repos/lifecycle/hard_delete.goadded
@@ -0,0 +1,121 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+	"os"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+
12
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
13
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
14
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
15
+)
16
+
17
+// HardDelete is the worker-driven cascade that runs after the soft-
18
+// delete grace expires. The order is explicit and auditable:
19
+//
20
+//  1. Anonymize forks-of-this-repo (clear fork_of_repo_id on children).
21
+//  2. Drop the redirect rows that point at this repo (other repos
22
+//     keeping us as their old name; keep redirects pointed away from
23
+//     us so old URLs of *this* repo's previous identity are still
24
+//     resolvable from the now-deleted row, until that repo is also
25
+//     deleted by ON DELETE CASCADE on the FK).
26
+//  3. DELETE FROM repos — FK cascades handle push_events,
27
+//     webhook_events_pending, repo_collaborators, transfer requests,
28
+//     and any redirect rows pointing at this id.
29
+//  4. RemoveAll the bare repo on disk.
30
+//  5. Audit-log the hard-delete with a snapshot of the removed row in
31
+//     the meta payload so the audit row is self-contained even after
32
+//     the repo_id is gone.
33
+//
34
+// The op refuses to run on a repo that isn't past its soft-delete
35
+// grace window — defense-in-depth even though the worker query
36
+// already gates this. ActorUserID is typically the worker's instance
37
+// id encoded in some way; pass 0 to record "system" in audit.
38
+func HardDelete(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
39
+	rq := reposdb.New()
40
+	uq := usersdb.New()
41
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
42
+	if err != nil {
43
+		return fmt.Errorf("load repo: %w", err)
44
+	}
45
+	if !repo.DeletedAt.Valid {
46
+		return ErrNotDeleted
47
+	}
48
+	if deps.now().Sub(repo.DeletedAt.Time) < softDeleteGrace {
49
+		return fmt.Errorf("hard-delete: %w", ErrPastGrace)
50
+		// Sentinel mismatch on purpose — the only "before grace"
51
+		// failure is the worker mis-firing. Reuse ErrPastGrace's
52
+		// inverse semantics to keep the error surface tight.
53
+	}
54
+
55
+	// Snapshot for the audit row before we mutate.
56
+	snapshot := map[string]any{
57
+		"id":         repo.ID,
58
+		"name":       repo.Name,
59
+		"visibility": string(repo.Visibility),
60
+	}
61
+	if repo.OwnerUserID.Valid {
62
+		snapshot["owner_user_id"] = repo.OwnerUserID.Int64
63
+	}
64
+	ownerName := ""
65
+	if repo.OwnerUserID.Valid {
66
+		if u, err := uq.GetUserByID(ctx, deps.Pool, repo.OwnerUserID.Int64); err == nil {
67
+			ownerName = u.Username
68
+		}
69
+	}
70
+
71
+	tx, err := deps.Pool.Begin(ctx)
72
+	if err != nil {
73
+		return fmt.Errorf("begin: %w", err)
74
+	}
75
+	committed := false
76
+	defer func() {
77
+		if !committed {
78
+			_ = tx.Rollback(ctx)
79
+		}
80
+	}()
81
+
82
+	// 1. Orphan child forks.
83
+	if _, err := rq.OrphanForksOf(ctx, tx, pgtype.Int8{Int64: repoID, Valid: true}); err != nil {
84
+		return fmt.Errorf("orphan forks: %w", err)
85
+	}
86
+
87
+	// 2. (Skipped — see comment in the doc block. The repo's outgoing
88
+	//    redirect rows go in step 3 via FK cascade.)
89
+
90
+	// 3. Drop the repo row. FK ON DELETE CASCADE on push_events,
91
+	//    repo_collaborators, repo_redirects, repo_transfer_requests,
92
+	//    and webhook_events_pending makes this a single SQL.
93
+	if err := rq.HardDeleteRepo(ctx, tx, repoID); err != nil {
94
+		return fmt.Errorf("delete repo: %w", err)
95
+	}
96
+	if err := tx.Commit(ctx); err != nil {
97
+		return fmt.Errorf("commit: %w", err)
98
+	}
99
+	committed = true
100
+
101
+	// 4. Remove the bare repo on disk. Best-effort: log and continue
102
+	//    so a missing path doesn't leave a "deleted but DB row gone"
103
+	//    inconsistency that's impossible to recover from.
104
+	if ownerName != "" {
105
+		if path, err := deps.RepoFS.RepoPath(ownerName, repo.Name); err == nil {
106
+			if err := os.RemoveAll(path); err != nil && deps.Logger != nil {
107
+				deps.Logger.WarnContext(ctx, "hard delete: fs RemoveAll failed",
108
+					"repo_id", repoID, "path", path, "error", err)
109
+			}
110
+		}
111
+	}
112
+
113
+	// 5. Audit. Use a fresh pool conn since the repo_id no longer
114
+	//    exists; the meta blob carries the snapshot.
115
+	if deps.Audit != nil {
116
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
117
+			audit.ActionRepoHardDeleted, audit.TargetRepo, repoID, snapshot)
118
+	}
119
+	return nil
120
+}
121
+
internal/repos/lifecycle/lifecycle.goadded
@@ -0,0 +1,76 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package lifecycle owns the post-creation mutations of a repo: rename,
4
+// transfer, archive/unarchive, visibility flip, soft-delete, restore,
5
+// and hard-delete. Every operation is a single function with strict
6
+// ordering of DB and FS work; recovery semantics are documented at
7
+// each call site.
8
+//
9
+// The package is policy-agnostic — callers (web handlers, the
10
+// hard-delete worker) are expected to have already passed the request
11
+// through policy.Can with the appropriate Action. Lifecycle's job is
12
+// to *execute* the change correctly, not decide who's allowed.
13
+package lifecycle
14
+
15
+import (
16
+	"errors"
17
+	"log/slog"
18
+	"time"
19
+
20
+	"github.com/jackc/pgx/v5/pgxpool"
21
+
22
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
23
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
24
+)
25
+
26
+// Deps wires the orchestrator. Construct once and pass to every Op.
27
+// All Ops are stateless w.r.t. Deps so a single value can serve many
28
+// concurrent requests.
29
+type Deps struct {
30
+	Pool   *pgxpool.Pool
31
+	RepoFS *storage.RepoFS
32
+	Audit  *audit.Recorder
33
+	Logger *slog.Logger
34
+	Now    func() time.Time
35
+}
36
+
37
+// now returns deps.Now() or wall time. Tests pin Now for determinism.
38
+func (d Deps) now() time.Time {
39
+	if d.Now != nil {
40
+		return d.Now()
41
+	}
42
+	return time.Now()
43
+}
44
+
45
+// Errors surfaced by lifecycle ops. Handlers map these to HTTP status
46
+// codes and friendly user messages.
47
+var (
48
+	ErrInvalidName       = errors.New("lifecycle: invalid name")
49
+	ErrReservedName      = errors.New("lifecycle: name is reserved")
50
+	ErrNameTaken         = errors.New("lifecycle: name already taken on owner")
51
+	ErrRenameRateLimited = errors.New("lifecycle: rename rate limit exceeded")
52
+	ErrSameName          = errors.New("lifecycle: new name equals current")
53
+	ErrTransferToSelf    = errors.New("lifecycle: transfer recipient is the same owner")
54
+	ErrTransferTerminal  = errors.New("lifecycle: transfer no longer pending")
55
+	ErrTransferExpired   = errors.New("lifecycle: transfer expired")
56
+	ErrAlreadyArchived   = errors.New("lifecycle: repo is already archived")
57
+	ErrNotArchived       = errors.New("lifecycle: repo is not archived")
58
+	ErrAlreadyDeleted    = errors.New("lifecycle: repo is already soft-deleted")
59
+	ErrNotDeleted        = errors.New("lifecycle: repo is not soft-deleted")
60
+	ErrPastGrace         = errors.New("lifecycle: soft-delete grace expired; restore unavailable")
61
+)
62
+
63
+// renameRateLimitWindow / renameRateLimitMax mirror the spec's lean.
64
+// Adjust together; the SQL counts redirects within the window.
65
+const (
66
+	renameRateLimitWindow = 30 * 24 * time.Hour
67
+	renameRateLimitMax    = 5
68
+
69
+	// transferTTL is the time a recipient has to accept before the
70
+	// offer auto-expires.
71
+	transferTTL = 7 * 24 * time.Hour
72
+
73
+	// softDeleteGrace is the window between soft-delete and the worker
74
+	// hard-deleting. Mirrored in the SQL `interval '7 days'`.
75
+	softDeleteGrace = 7 * 24 * time.Hour
76
+)
internal/repos/lifecycle/lifecycle_test.goadded
@@ -0,0 +1,334 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"os"
9
+	"path/filepath"
10
+	"testing"
11
+	"time"
12
+
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
16
+	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
17
+	"github.com/tenseleyFlow/shithub/internal/infra/storage"
18
+	"github.com/tenseleyFlow/shithub/internal/repos"
19
+	"github.com/tenseleyFlow/shithub/internal/repos/lifecycle"
20
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
22
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
23
+)
24
+
25
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
26
+	"AAAAAAAAAAAAAAAA$" +
27
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
28
+
29
+type env struct {
30
+	deps  lifecycle.Deps
31
+	rdeps repos.Deps
32
+
33
+	alice usersdb.User
34
+	bob   usersdb.User
35
+
36
+	repoID     int64
37
+	originalFS string
38
+}
39
+
40
+func setup(t *testing.T) *env {
41
+	t.Helper()
42
+	pool := dbtest.NewTestDB(t)
43
+	root := t.TempDir()
44
+	rfs, err := storage.NewRepoFS(root)
45
+	if err != nil {
46
+		t.Fatalf("NewRepoFS: %v", err)
47
+	}
48
+	uq := usersdb.New()
49
+	mk := func(name string) usersdb.User {
50
+		u, err := uq.CreateUser(context.Background(), pool, usersdb.CreateUserParams{
51
+			Username: name, DisplayName: name, PasswordHash: fixtureHash,
52
+		})
53
+		if err != nil {
54
+			t.Fatalf("CreateUser %s: %v", name, err)
55
+		}
56
+		em, err := uq.CreateUserEmail(context.Background(), pool, usersdb.CreateUserEmailParams{
57
+			UserID: u.ID, Email: name + "@example.com", IsPrimary: true, Verified: true,
58
+		})
59
+		if err != nil {
60
+			t.Fatalf("CreateUserEmail %s: %v", name, err)
61
+		}
62
+		_ = uq.LinkUserPrimaryEmail(context.Background(), pool, usersdb.LinkUserPrimaryEmailParams{
63
+			ID: u.ID, PrimaryEmailID: pgtype.Int8{Int64: em.ID, Valid: true},
64
+		})
65
+		return u
66
+	}
67
+	alice := mk("alice")
68
+	bob := mk("bob")
69
+
70
+	rdeps := repos.Deps{
71
+		Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder(), Limiter: throttle.NewLimiter(),
72
+	}
73
+	res, err := repos.Create(context.Background(), rdeps, repos.Params{
74
+		OwnerUserID: alice.ID, OwnerUsername: alice.Username,
75
+		Name: "demo", Visibility: "public", InitReadme: true,
76
+	})
77
+	if err != nil {
78
+		t.Fatalf("repos.Create: %v", err)
79
+	}
80
+
81
+	return &env{
82
+		deps:  lifecycle.Deps{Pool: pool, RepoFS: rfs, Audit: audit.NewRecorder()},
83
+		rdeps: rdeps,
84
+		alice: alice, bob: bob,
85
+		repoID:     res.Repo.ID,
86
+		originalFS: res.DiskPath,
87
+	}
88
+}
89
+
90
+func TestRename_HappyPath(t *testing.T) {
91
+	t.Parallel()
92
+	env := setup(t)
93
+	if err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
94
+		ActorUserID: env.alice.ID,
95
+		RepoID:      env.repoID,
96
+		OwnerUserID: env.alice.ID,
97
+		OwnerName:   "alice",
98
+		OldName:     "demo",
99
+		NewName:     "renamed",
100
+	}); err != nil {
101
+		t.Fatalf("Rename: %v", err)
102
+	}
103
+	rq := reposdb.New()
104
+	repo, err := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
105
+	if err != nil {
106
+		t.Fatalf("GetRepoByID: %v", err)
107
+	}
108
+	if repo.Name != "renamed" {
109
+		t.Errorf("name = %q, want renamed", repo.Name)
110
+	}
111
+	// Redirect row exists.
112
+	rid, err := rq.LookupRedirectByUserOwner(context.Background(), env.deps.Pool, reposdb.LookupRedirectByUserOwnerParams{
113
+		OldOwnerUserID: pgtype.Int8{Int64: env.alice.ID, Valid: true},
114
+		OldName:        "demo",
115
+	})
116
+	if err != nil || rid != env.repoID {
117
+		t.Errorf("LookupRedirect: id=%d err=%v", rid, err)
118
+	}
119
+	// FS dir moved.
120
+	newPath := filepath.Join(filepath.Dir(env.originalFS), "renamed.git")
121
+	if _, err := os.Stat(newPath); err != nil {
122
+		t.Errorf("new path missing: %v", err)
123
+	}
124
+}
125
+
126
+func TestRename_RejectsSameName(t *testing.T) {
127
+	t.Parallel()
128
+	env := setup(t)
129
+	err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
130
+		RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
131
+		OldName: "demo", NewName: "demo",
132
+	})
133
+	if !errors.Is(err, lifecycle.ErrSameName) {
134
+		t.Errorf("err = %v, want ErrSameName", err)
135
+	}
136
+}
137
+
138
+func TestRename_RateLimit(t *testing.T) {
139
+	t.Parallel()
140
+	env := setup(t)
141
+	for i := 0; i < 5; i++ {
142
+		newName := []string{"a1", "a2", "a3", "a4", "a5"}[i]
143
+		oldName := "demo"
144
+		if i > 0 {
145
+			oldName = []string{"a1", "a2", "a3", "a4"}[i-1]
146
+		}
147
+		if err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
148
+			RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
149
+			OldName: oldName, NewName: newName,
150
+		}); err != nil {
151
+			t.Fatalf("rename %d: %v", i, err)
152
+		}
153
+	}
154
+	err := lifecycle.Rename(context.Background(), env.deps, lifecycle.RenameParams{
155
+		RepoID: env.repoID, OwnerUserID: env.alice.ID, OwnerName: "alice",
156
+		OldName: "a5", NewName: "a6",
157
+	})
158
+	if !errors.Is(err, lifecycle.ErrRenameRateLimited) {
159
+		t.Errorf("6th rename: err=%v, want ErrRenameRateLimited", err)
160
+	}
161
+}
162
+
163
+func TestArchiveUnarchive(t *testing.T) {
164
+	t.Parallel()
165
+	env := setup(t)
166
+	if err := lifecycle.Archive(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
167
+		t.Fatalf("Archive: %v", err)
168
+	}
169
+	if err := lifecycle.Archive(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrAlreadyArchived) {
170
+		t.Errorf("double archive: err=%v, want ErrAlreadyArchived", err)
171
+	}
172
+	if err := lifecycle.Unarchive(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
173
+		t.Fatalf("Unarchive: %v", err)
174
+	}
175
+	if err := lifecycle.Unarchive(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrNotArchived) {
176
+		t.Errorf("double unarchive: err=%v, want ErrNotArchived", err)
177
+	}
178
+}
179
+
180
+func TestSetVisibility(t *testing.T) {
181
+	t.Parallel()
182
+	env := setup(t)
183
+	if err := lifecycle.SetVisibility(context.Background(), env.deps, env.alice.ID, env.repoID, "private"); err != nil {
184
+		t.Fatalf("SetVisibility: %v", err)
185
+	}
186
+	rq := reposdb.New()
187
+	repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
188
+	if string(repo.Visibility) != "private" {
189
+		t.Errorf("Visibility = %q, want private", repo.Visibility)
190
+	}
191
+	if err := lifecycle.SetVisibility(context.Background(), env.deps, env.alice.ID, env.repoID, "bogus"); !errors.Is(err, lifecycle.ErrInvalidVisibility) {
192
+		t.Errorf("invalid: err=%v, want ErrInvalidVisibility", err)
193
+	}
194
+}
195
+
196
+func TestSoftDeleteAndRestore(t *testing.T) {
197
+	t.Parallel()
198
+	env := setup(t)
199
+	if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
200
+		t.Fatalf("SoftDelete: %v", err)
201
+	}
202
+	if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrAlreadyDeleted) {
203
+		t.Errorf("double soft-delete: err=%v", err)
204
+	}
205
+	if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
206
+		t.Fatalf("Restore: %v", err)
207
+	}
208
+	if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrNotDeleted) {
209
+		t.Errorf("double restore: err=%v", err)
210
+	}
211
+}
212
+
213
+func TestRestore_PastGraceRefuses(t *testing.T) {
214
+	t.Parallel()
215
+	env := setup(t)
216
+	// Pin Now to 8 days from now after a soft-delete to simulate
217
+	// past-grace state.
218
+	if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
219
+		t.Fatal(err)
220
+	}
221
+	env.deps.Now = func() time.Time { return time.Now().Add(8 * 24 * time.Hour) }
222
+	if err := lifecycle.Restore(context.Background(), env.deps, env.alice.ID, env.repoID); !errors.Is(err, lifecycle.ErrPastGrace) {
223
+		t.Errorf("past-grace restore: err=%v, want ErrPastGrace", err)
224
+	}
225
+}
226
+
227
+func TestTransfer_AcceptHappyPath(t *testing.T) {
228
+	t.Parallel()
229
+	env := setup(t)
230
+	id, err := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
231
+		ActorUserID: env.alice.ID, RepoID: env.repoID,
232
+		FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
233
+	})
234
+	if err != nil {
235
+		t.Fatalf("RequestTransfer: %v", err)
236
+	}
237
+	if err := lifecycle.AcceptTransfer(context.Background(), env.deps, env.bob.ID, id); err != nil {
238
+		t.Fatalf("AcceptTransfer: %v", err)
239
+	}
240
+	rq := reposdb.New()
241
+	repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
242
+	if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != env.bob.ID {
243
+		t.Errorf("owner_user_id = %v, want %d", repo.OwnerUserID, env.bob.ID)
244
+	}
245
+	// Redirect from alice/demo → repo_id should resolve.
246
+	rid, err := rq.LookupRedirectByUserOwner(context.Background(), env.deps.Pool, reposdb.LookupRedirectByUserOwnerParams{
247
+		OldOwnerUserID: pgtype.Int8{Int64: env.alice.ID, Valid: true},
248
+		OldName:        "demo",
249
+	})
250
+	if err != nil || rid != env.repoID {
251
+		t.Errorf("redirect: id=%d err=%v", rid, err)
252
+	}
253
+}
254
+
255
+func TestTransfer_DeclineLeavesOwnerUnchanged(t *testing.T) {
256
+	t.Parallel()
257
+	env := setup(t)
258
+	id, _ := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
259
+		ActorUserID: env.alice.ID, RepoID: env.repoID,
260
+		FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
261
+	})
262
+	if err := lifecycle.DeclineTransfer(context.Background(), env.deps, env.bob.ID, id); err != nil {
263
+		t.Fatalf("DeclineTransfer: %v", err)
264
+	}
265
+	rq := reposdb.New()
266
+	repo, _ := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID)
267
+	if !repo.OwnerUserID.Valid || repo.OwnerUserID.Int64 != env.alice.ID {
268
+		t.Errorf("owner changed despite decline: %v", repo.OwnerUserID)
269
+	}
270
+}
271
+
272
+func TestTransfer_CancelByOwner(t *testing.T) {
273
+	t.Parallel()
274
+	env := setup(t)
275
+	id, _ := lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
276
+		ActorUserID: env.alice.ID, RepoID: env.repoID,
277
+		FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
278
+	})
279
+	if err := lifecycle.CancelTransfer(context.Background(), env.deps, env.alice.ID, id); err != nil {
280
+		t.Fatalf("CancelTransfer: %v", err)
281
+	}
282
+	// Bob's accept now fails.
283
+	if err := lifecycle.AcceptTransfer(context.Background(), env.deps, env.bob.ID, id); !errors.Is(err, lifecycle.ErrTransferTerminal) {
284
+		t.Errorf("accept-after-cancel: err=%v, want ErrTransferTerminal", err)
285
+	}
286
+}
287
+
288
+func TestTransfer_ExpireSweepFlipsPending(t *testing.T) {
289
+	t.Parallel()
290
+	env := setup(t)
291
+	_, _ = lifecycle.RequestTransfer(context.Background(), env.deps, lifecycle.TransferRequestParams{
292
+		ActorUserID: env.alice.ID, RepoID: env.repoID,
293
+		FromUserID: env.alice.ID, ToPrincipalKind: "user", ToPrincipalID: env.bob.ID,
294
+	})
295
+	// Force the row past its expires_at by SQL update so we don't need
296
+	// to advance lifecycle.now() through the call path.
297
+	_, err := env.deps.Pool.Exec(context.Background(),
298
+		`UPDATE repo_transfer_requests SET expires_at = now() - interval '1 minute' WHERE repo_id = $1`, env.repoID)
299
+	if err != nil {
300
+		t.Fatalf("force expiry: %v", err)
301
+	}
302
+	n, err := lifecycle.ExpirePending(context.Background(), env.deps)
303
+	if err != nil {
304
+		t.Fatalf("ExpirePending: %v", err)
305
+	}
306
+	if n != 1 {
307
+		t.Errorf("expired count = %d, want 1", n)
308
+	}
309
+}
310
+
311
+func TestHardDelete_PastGraceCascades(t *testing.T) {
312
+	t.Parallel()
313
+	env := setup(t)
314
+	if err := lifecycle.SoftDelete(context.Background(), env.deps, env.alice.ID, env.repoID); err != nil {
315
+		t.Fatal(err)
316
+	}
317
+	// Force deleted_at past grace and pin Now likewise.
318
+	_, _ = env.deps.Pool.Exec(context.Background(),
319
+		`UPDATE repos SET deleted_at = now() - interval '8 days' WHERE id = $1`, env.repoID)
320
+	env.deps.Now = func() time.Time { return time.Now() }
321
+
322
+	if err := lifecycle.HardDelete(context.Background(), env.deps, 0, env.repoID); err != nil {
323
+		t.Fatalf("HardDelete: %v", err)
324
+	}
325
+	// Repo row gone.
326
+	rq := reposdb.New()
327
+	if _, err := rq.GetRepoByID(context.Background(), env.deps.Pool, env.repoID); err == nil {
328
+		t.Errorf("repo row still present after hard-delete")
329
+	}
330
+	// FS dir gone.
331
+	if _, err := os.Stat(env.originalFS); !os.IsNotExist(err) {
332
+		t.Errorf("FS path still present: err=%v", err)
333
+	}
334
+}
internal/repos/lifecycle/rename.goadded
@@ -0,0 +1,138 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+	"os"
9
+	"strings"
10
+
11
+	"github.com/jackc/pgx/v5/pgconn"
12
+	"github.com/jackc/pgx/v5/pgtype"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/repos"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+)
18
+
19
+// RenameParams is what a same-owner rename takes.
20
+type RenameParams struct {
21
+	ActorUserID int64 // who is initiating; recorded in audit
22
+	RepoID      int64
23
+	OwnerUserID int64  // current owner — also gives us the FS path
24
+	OwnerName   string // current owner username (for FS path)
25
+	OldName     string // current repo name
26
+	NewName     string // requested name
27
+}
28
+
29
+// Rename performs a same-owner rename: validate → tx (insert redirect,
30
+// update repos.name) → FS Move → audit. On FS failure the DB tx is
31
+// reverted via a compensating UPDATE so the persistent state is
32
+// consistent. The caller is responsible for policy.Can; we don't
33
+// re-check here.
34
+func Rename(ctx context.Context, deps Deps, p RenameParams) error {
35
+	newName := strings.ToLower(strings.TrimSpace(p.NewName))
36
+	if newName == strings.ToLower(p.OldName) {
37
+		return ErrSameName
38
+	}
39
+	if err := repos.ValidateName(newName); err != nil {
40
+		return fmt.Errorf("%w: %v", ErrInvalidName, err)
41
+	}
42
+
43
+	// Rate limit: count recent renames (= redirect rows for this repo).
44
+	rq := reposdb.New()
45
+	count, err := rq.CountRecentRedirectsForRepo(ctx, deps.Pool, p.RepoID)
46
+	if err != nil {
47
+		return fmt.Errorf("count redirects: %w", err)
48
+	}
49
+	if int(count) >= renameRateLimitMax {
50
+		return ErrRenameRateLimited
51
+	}
52
+
53
+	tx, err := deps.Pool.Begin(ctx)
54
+	if err != nil {
55
+		return fmt.Errorf("begin: %w", err)
56
+	}
57
+	committed := false
58
+	defer func() {
59
+		if !committed {
60
+			_ = tx.Rollback(ctx)
61
+		}
62
+	}()
63
+
64
+	// 1. Insert redirect row before mutating the repo so a unique-name
65
+	//    collision rolls back both with a single ROLLBACK.
66
+	if err := rq.InsertRepoRedirect(ctx, tx, reposdb.InsertRepoRedirectParams{
67
+		OldOwnerUserID: pgtype.Int8{Int64: p.OwnerUserID, Valid: true},
68
+		OldOwnerOrgID:  pgtype.Int8{Valid: false},
69
+		OldName:        p.OldName,
70
+		RepoID:         p.RepoID,
71
+	}); err != nil {
72
+		return fmt.Errorf("insert redirect: %w", err)
73
+	}
74
+
75
+	// 2. Update the name. Unique-violation here means the new name is
76
+	//    taken on this owner.
77
+	if err := rq.RenameRepo(ctx, tx, reposdb.RenameRepoParams{ID: p.RepoID, Name: newName}); err != nil {
78
+		var pgErr *pgconn.PgError
79
+		if errAs(err, &pgErr) && pgErr.Code == "23505" {
80
+			return ErrNameTaken
81
+		}
82
+		return fmt.Errorf("rename repo: %w", err)
83
+	}
84
+
85
+	if err := tx.Commit(ctx); err != nil {
86
+		return fmt.Errorf("commit: %w", err)
87
+	}
88
+	committed = true
89
+
90
+	// 3. FS rename. On failure, run the compensating SQL so DB matches
91
+	//    disk again. The compensating tx is best-effort logged; if it
92
+	//    also fails the operator must reconcile by hand (rare path).
93
+	oldPath, e1 := deps.RepoFS.RepoPath(p.OwnerName, p.OldName)
94
+	newPath, e2 := deps.RepoFS.RepoPath(p.OwnerName, newName)
95
+	if e1 != nil || e2 != nil {
96
+		compensateRename(ctx, deps, p.RepoID, p.OldName, p.OwnerUserID, newName)
97
+		return fmt.Errorf("repo path resolution: e1=%v e2=%v", e1, e2)
98
+	}
99
+	if _, err := os.Stat(oldPath); err == nil {
100
+		if err := deps.RepoFS.Move(oldPath, newPath); err != nil {
101
+			compensateRename(ctx, deps, p.RepoID, p.OldName, p.OwnerUserID, newName)
102
+			return fmt.Errorf("fs move: %w", err)
103
+		}
104
+	}
105
+	// (oldPath missing is acceptable — the on-disk repo may not exist
106
+	// yet for a freshly-created repo whose initial commit hasn't been
107
+	// pushed by the user. The bare-repo init is creation-time; rename
108
+	// just needs to handle the present-or-absent case symmetrically.)
109
+
110
+	if deps.Audit != nil {
111
+		_ = deps.Audit.Record(ctx, deps.Pool, p.ActorUserID,
112
+			audit.ActionRepoRenamed, audit.TargetRepo, p.RepoID,
113
+			map[string]any{"old_name": p.OldName, "new_name": newName})
114
+	}
115
+	return nil
116
+}
117
+
118
+// compensateRename undoes the redirect+name change after an FS failure.
119
+// Logged at warn so the operator notices but the request still returns
120
+// the original error (the caller sees the FS error, which is the user-
121
+// visible truth).
122
+func compensateRename(ctx context.Context, deps Deps, repoID int64, oldName string, ownerUserID int64, newName string) {
123
+	rq := reposdb.New()
124
+	if err := rq.RenameRepo(ctx, deps.Pool, reposdb.RenameRepoParams{ID: repoID, Name: oldName}); err != nil {
125
+		if deps.Logger != nil {
126
+			deps.Logger.WarnContext(ctx, "rename: compensating UPDATE failed", "repo_id", repoID, "error", err)
127
+		}
128
+	}
129
+	// Drop the redirect row we just wrote — it now points at a name
130
+	// that's about to come back to its old self.
131
+	_, err := deps.Pool.Exec(ctx,
132
+		`DELETE FROM repo_redirects WHERE repo_id = $1 AND old_owner_user_id = $2 AND old_name = $3`,
133
+		repoID, ownerUserID, oldName)
134
+	if err != nil && deps.Logger != nil {
135
+		deps.Logger.WarnContext(ctx, "rename: compensating redirect-delete failed", "repo_id", repoID, "error", err)
136
+	}
137
+	_ = newName // kept in signature for symmetry / future logging
138
+}
internal/repos/lifecycle/soft_delete.goadded
@@ -0,0 +1,70 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"fmt"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
10
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
11
+)
12
+
13
+// SoftDelete sets repos.deleted_at to now. The repo disappears from
14
+// listings and the home page returns 404 for non-owners (auth-aware).
15
+// The bare repo on disk is left alone until the hard-delete worker
16
+// runs at the end of the grace window.
17
+func SoftDelete(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
18
+	rq := reposdb.New()
19
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
20
+	if err != nil {
21
+		return fmt.Errorf("load repo: %w", err)
22
+	}
23
+	if repo.DeletedAt.Valid {
24
+		return ErrAlreadyDeleted
25
+	}
26
+	if err := rq.SoftDeleteRepoLifecycle(ctx, deps.Pool, repoID); err != nil {
27
+		return fmt.Errorf("soft delete: %w", err)
28
+	}
29
+	if deps.Audit != nil {
30
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
31
+			audit.ActionRepoSoftDeleted, audit.TargetRepo, repoID,
32
+			map[string]any{"name": repo.Name, "owner_user_id": int64ValueOrZero(repo.OwnerUserID.Int64, repo.OwnerUserID.Valid)})
33
+	}
34
+	return nil
35
+}
36
+
37
+// Restore clears repos.deleted_at within the grace window. Past the
38
+// window the row may still exist (worker hasn't run yet) but we
39
+// refuse — the operator-visible contract is "7 days, then it's gone".
40
+func Restore(ctx context.Context, deps Deps, actorUserID, repoID int64) error {
41
+	rq := reposdb.New()
42
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
43
+	if err != nil {
44
+		return fmt.Errorf("load repo: %w", err)
45
+	}
46
+	if !repo.DeletedAt.Valid {
47
+		return ErrNotDeleted
48
+	}
49
+	if deps.now().Sub(repo.DeletedAt.Time) > softDeleteGrace {
50
+		return ErrPastGrace
51
+	}
52
+	if err := rq.RestoreRepo(ctx, deps.Pool, repoID); err != nil {
53
+		return fmt.Errorf("restore: %w", err)
54
+	}
55
+	if deps.Audit != nil {
56
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
57
+			audit.ActionRepoRestored, audit.TargetRepo, repoID, nil)
58
+	}
59
+	return nil
60
+}
61
+
62
+// int64ValueOrZero unwraps a pgtype.Int8 stored as raw int64+bool. We
63
+// keep this private helper duplicated across packages rather than
64
+// pulling pgtype into the audit-meta hot path.
65
+func int64ValueOrZero(v int64, valid bool) int64 {
66
+	if valid {
67
+		return v
68
+	}
69
+	return 0
70
+}
internal/repos/lifecycle/transfer.goadded
@@ -0,0 +1,274 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+	"os"
10
+
11
+	"github.com/jackc/pgx/v5"
12
+	"github.com/jackc/pgx/v5/pgconn"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
16
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
18
+)
19
+
20
+// TransferRequestParams describes a new transfer offer.
21
+type TransferRequestParams struct {
22
+	ActorUserID    int64
23
+	RepoID         int64
24
+	FromUserID     int64
25
+	ToPrincipalKind string // "user" — "org" arrives in S31
26
+	ToPrincipalID  int64
27
+}
28
+
29
+// RequestTransfer creates a pending transfer with a 7-day TTL. Returns
30
+// the new request id. Confirmation typing ("type owner/repo to confirm")
31
+// belongs in the handler — this function trusts its inputs.
32
+func RequestTransfer(ctx context.Context, deps Deps, p TransferRequestParams) (int64, error) {
33
+	if p.ToPrincipalKind != "user" {
34
+		return 0, fmt.Errorf("transfer: principal kind %q not supported in S16 (org transfers in S31)", p.ToPrincipalKind)
35
+	}
36
+	if p.ToPrincipalID == p.FromUserID {
37
+		return 0, ErrTransferToSelf
38
+	}
39
+
40
+	rq := reposdb.New()
41
+	row, err := rq.InsertTransferRequest(ctx, deps.Pool, reposdb.InsertTransferRequestParams{
42
+		RepoID:           p.RepoID,
43
+		FromUserID:       p.FromUserID,
44
+		ToPrincipalKind:  reposdb.TransferPrincipalKind(p.ToPrincipalKind),
45
+		ToPrincipalID:    p.ToPrincipalID,
46
+		CreatedBy:        p.ActorUserID,
47
+		ExpiresAt:        pgtype.Timestamptz{Time: deps.now().Add(transferTTL), Valid: true},
48
+	})
49
+	if err != nil {
50
+		return 0, fmt.Errorf("insert transfer: %w", err)
51
+	}
52
+	if deps.Audit != nil {
53
+		_ = deps.Audit.Record(ctx, deps.Pool, p.ActorUserID,
54
+			audit.ActionRepoTransferRequested, audit.TargetRepo, p.RepoID,
55
+			map[string]any{
56
+				"to_principal_kind": p.ToPrincipalKind,
57
+				"to_principal_id":   p.ToPrincipalID,
58
+				"transfer_id":       row.ID,
59
+			})
60
+	}
61
+	return row.ID, nil
62
+}
63
+
64
+// AcceptTransfer flips the transfer to accepted, updates the repo's
65
+// owner, writes a redirect row from the old owner+name, and renames
66
+// the bare repo on disk if the recipient also has a different name
67
+// in mind (rare; defaults to keeping the same name).
68
+//
69
+// The recipient is the actor in this op. We re-check status under the
70
+// row lock so concurrent accept/cancel/decline races are settled
71
+// deterministically by the DB.
72
+func AcceptTransfer(ctx context.Context, deps Deps, actorUserID, transferID int64) error {
73
+	rq := reposdb.New()
74
+	uq := usersdb.New()
75
+
76
+	tx, err := deps.Pool.Begin(ctx)
77
+	if err != nil {
78
+		return fmt.Errorf("begin: %w", err)
79
+	}
80
+	committed := false
81
+	defer func() {
82
+		if !committed {
83
+			_ = tx.Rollback(ctx)
84
+		}
85
+	}()
86
+
87
+	// Lock the transfer row to serialize racing accept/decline/cancel.
88
+	row, err := lockTransfer(ctx, tx, transferID)
89
+	if err != nil {
90
+		return err
91
+	}
92
+	if row.Status != reposdb.TransferStatusPending {
93
+		return ErrTransferTerminal
94
+	}
95
+	if !row.ExpiresAt.Valid || deps.now().After(row.ExpiresAt.Time) {
96
+		return ErrTransferExpired
97
+	}
98
+	if row.ToPrincipalKind != reposdb.TransferPrincipalKindUser || row.ToPrincipalID != actorUserID {
99
+		// The actor isn't the recipient — a 403 at the handler.
100
+		return ErrTransferTerminal
101
+	}
102
+
103
+	repo, err := rq.GetRepoByID(ctx, tx, row.RepoID)
104
+	if err != nil {
105
+		return fmt.Errorf("load repo: %w", err)
106
+	}
107
+	oldOwnerUserID := int64(0)
108
+	if repo.OwnerUserID.Valid {
109
+		oldOwnerUserID = repo.OwnerUserID.Int64
110
+	}
111
+
112
+	// Insert redirect from the old owner/name.
113
+	if err := rq.InsertRepoRedirect(ctx, tx, reposdb.InsertRepoRedirectParams{
114
+		OldOwnerUserID: pgtype.Int8{Int64: oldOwnerUserID, Valid: oldOwnerUserID != 0},
115
+		OldOwnerOrgID:  pgtype.Int8{Valid: false},
116
+		OldName:        repo.Name,
117
+		RepoID:         repo.ID,
118
+	}); err != nil {
119
+		return fmt.Errorf("insert redirect: %w", err)
120
+	}
121
+
122
+	// Update owner. We keep the same name; if it collides on the
123
+	// recipient, the unique index trips and the tx rolls back.
124
+	if err := rq.TransferRepoOwner(ctx, tx, reposdb.TransferRepoOwnerParams{
125
+		ID:           repo.ID,
126
+		Name:         repo.Name,
127
+		OwnerUserID:  pgtype.Int8{Int64: actorUserID, Valid: true},
128
+		OwnerOrgID:   pgtype.Int8{Valid: false},
129
+	}); err != nil {
130
+		var pgErr *pgconn.PgError
131
+		if errAs(err, &pgErr) && pgErr.Code == "23505" {
132
+			return ErrNameTaken
133
+		}
134
+		return fmt.Errorf("transfer owner: %w", err)
135
+	}
136
+
137
+	// Mark the transfer accepted.
138
+	if err := rq.AcceptTransferRequest(ctx, tx, transferID); err != nil {
139
+		return fmt.Errorf("accept request: %w", err)
140
+	}
141
+
142
+	if err := tx.Commit(ctx); err != nil {
143
+		return fmt.Errorf("commit: %w", err)
144
+	}
145
+	committed = true
146
+
147
+	// FS move from the old owner's shard to the recipient's. Best-
148
+	// effort: if the on-disk repo doesn't exist (never pushed), skip.
149
+	oldOwner, err := uq.GetUserByID(ctx, deps.Pool, oldOwnerUserID)
150
+	if err != nil {
151
+		// Old owner row gone (recently deleted) — nothing to move.
152
+		return nil
153
+	}
154
+	newOwner, err := uq.GetUserByID(ctx, deps.Pool, actorUserID)
155
+	if err != nil {
156
+		// Should never happen — recipient is the actor — but don't roll
157
+		// back FS-wise on this rare error; the DB is the source of
158
+		// truth and an operator can reconcile the disk path later.
159
+		if deps.Logger != nil {
160
+			deps.Logger.WarnContext(ctx, "transfer accept: new owner lookup", "error", err)
161
+		}
162
+		return nil
163
+	}
164
+	oldPath, e1 := deps.RepoFS.RepoPath(oldOwner.Username, repo.Name)
165
+	newPath, e2 := deps.RepoFS.RepoPath(newOwner.Username, repo.Name)
166
+	if e1 != nil || e2 != nil {
167
+		if deps.Logger != nil {
168
+			deps.Logger.WarnContext(ctx, "transfer accept: path resolve", "e1", e1, "e2", e2)
169
+		}
170
+		return nil
171
+	}
172
+	if _, err := os.Stat(oldPath); err == nil {
173
+		if err := deps.RepoFS.Move(oldPath, newPath); err != nil {
174
+			if deps.Logger != nil {
175
+				deps.Logger.WarnContext(ctx, "transfer accept: fs move", "error", err)
176
+			}
177
+			return nil
178
+		}
179
+	}
180
+
181
+	if deps.Audit != nil {
182
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
183
+			audit.ActionRepoTransferAccepted, audit.TargetRepo, repo.ID,
184
+			map[string]any{"transfer_id": transferID, "from_user_id": oldOwnerUserID})
185
+	}
186
+	return nil
187
+}
188
+
189
+// DeclineTransfer flips the row to declined. Recipient is the actor.
190
+func DeclineTransfer(ctx context.Context, deps Deps, actorUserID, transferID int64) error {
191
+	rq := reposdb.New()
192
+	row, err := rq.GetTransferRequest(ctx, deps.Pool, transferID)
193
+	if err != nil {
194
+		return fmt.Errorf("load transfer: %w", err)
195
+	}
196
+	if row.Status != reposdb.TransferStatusPending {
197
+		return ErrTransferTerminal
198
+	}
199
+	if row.ToPrincipalKind != reposdb.TransferPrincipalKindUser || row.ToPrincipalID != actorUserID {
200
+		return ErrTransferTerminal
201
+	}
202
+	if err := rq.DeclineTransferRequest(ctx, deps.Pool, transferID); err != nil {
203
+		return fmt.Errorf("decline: %w", err)
204
+	}
205
+	if deps.Audit != nil {
206
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
207
+			audit.ActionRepoTransferDeclined, audit.TargetRepo, row.RepoID,
208
+			map[string]any{"transfer_id": transferID})
209
+	}
210
+	return nil
211
+}
212
+
213
+// CancelTransfer is the sender pulling the offer. Repo-admin actors
214
+// (owner) can cancel.
215
+func CancelTransfer(ctx context.Context, deps Deps, actorUserID, transferID int64) error {
216
+	rq := reposdb.New()
217
+	row, err := rq.GetTransferRequest(ctx, deps.Pool, transferID)
218
+	if err != nil {
219
+		return fmt.Errorf("load transfer: %w", err)
220
+	}
221
+	if row.Status != reposdb.TransferStatusPending {
222
+		return ErrTransferTerminal
223
+	}
224
+	if err := rq.CancelTransferRequest(ctx, deps.Pool, transferID); err != nil {
225
+		return fmt.Errorf("cancel: %w", err)
226
+	}
227
+	if deps.Audit != nil {
228
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
229
+			audit.ActionRepoTransferCanceled, audit.TargetRepo, row.RepoID,
230
+			map[string]any{"transfer_id": transferID})
231
+	}
232
+	return nil
233
+}
234
+
235
+// ExpirePending sweeps every pending transfer past its expires_at.
236
+// Returns the count of rows flipped — the worker uses this for
237
+// observability. Audit logs are emitted in bulk via a single row that
238
+// records the count; per-transfer audit lines would be noisy and the
239
+// individual transfer rows themselves carry the timestamp.
240
+func ExpirePending(ctx context.Context, deps Deps) (int64, error) {
241
+	rq := reposdb.New()
242
+	n, err := rq.ExpirePendingTransfers(ctx, deps.Pool)
243
+	if err != nil {
244
+		return 0, fmt.Errorf("expire: %w", err)
245
+	}
246
+	return n, nil
247
+}
248
+
249
+// lockTransfer reads the row inside a tx with FOR UPDATE so concurrent
250
+// accept/decline/cancel can't double-fire. Returns the row directly —
251
+// the caller already has the transferID in scope.
252
+func lockTransfer(ctx context.Context, tx pgx.Tx, transferID int64) (reposdb.RepoTransferRequest, error) {
253
+	row, err := tx.Query(ctx,
254
+		`SELECT id, repo_id, from_user_id, to_principal_kind, to_principal_id,
255
+                created_by, created_at, expires_at, status,
256
+                accepted_at, declined_at, canceled_at
257
+         FROM repo_transfer_requests
258
+         WHERE id = $1
259
+         FOR UPDATE`, transferID)
260
+	if err != nil {
261
+		return reposdb.RepoTransferRequest{}, fmt.Errorf("lock transfer: %w", err)
262
+	}
263
+	defer row.Close()
264
+	if !row.Next() {
265
+		return reposdb.RepoTransferRequest{}, errors.New("transfer not found")
266
+	}
267
+	var r reposdb.RepoTransferRequest
268
+	if err := row.Scan(&r.ID, &r.RepoID, &r.FromUserID, &r.ToPrincipalKind, &r.ToPrincipalID,
269
+		&r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &r.Status,
270
+		&r.AcceptedAt, &r.DeclinedAt, &r.CanceledAt); err != nil {
271
+		return reposdb.RepoTransferRequest{}, fmt.Errorf("scan transfer: %w", err)
272
+	}
273
+	return r, nil
274
+}
internal/repos/lifecycle/visibility.goadded
@@ -0,0 +1,51 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package lifecycle
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"fmt"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
11
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
12
+)
13
+
14
+// ErrInvalidVisibility is returned when a caller passes a value that
15
+// isn't "public" or "private". Handlers should map to a 400.
16
+var ErrInvalidVisibility = errors.New("lifecycle: visibility must be public or private")
17
+
18
+// SetVisibility flips a repo between public and private. Forks remain
19
+// independent (separate repos at the data layer), and existing clones
20
+// already pulled remain — that's git's nature, not a bug. The UI is
21
+// responsible for surfacing the "future clones will be private"
22
+// caveat to the user.
23
+func SetVisibility(ctx context.Context, deps Deps, actorUserID, repoID int64, newVisibility string) error {
24
+	switch newVisibility {
25
+	case "public", "private":
26
+	default:
27
+		return ErrInvalidVisibility
28
+	}
29
+
30
+	rq := reposdb.New()
31
+	repo, err := rq.GetRepoByID(ctx, deps.Pool, repoID)
32
+	if err != nil {
33
+		return fmt.Errorf("load repo: %w", err)
34
+	}
35
+	if string(repo.Visibility) == newVisibility {
36
+		return nil // idempotent no-op
37
+	}
38
+
39
+	if err := rq.SetRepoVisibility(ctx, deps.Pool, reposdb.SetRepoVisibilityParams{
40
+		ID:         repoID,
41
+		Visibility: reposdb.RepoVisibility(newVisibility),
42
+	}); err != nil {
43
+		return fmt.Errorf("set visibility: %w", err)
44
+	}
45
+	if deps.Audit != nil {
46
+		_ = deps.Audit.Record(ctx, deps.Pool, actorUserID,
47
+			audit.ActionRepoVisibilityChanged, audit.TargetRepo, repoID,
48
+			map[string]any{"from": string(repo.Visibility), "to": newVisibility})
49
+	}
50
+	return nil
51
+}