tenseleyflow/shithub / 9c78d0f

Browse files

S16: docs/internal/repo-lifecycle.md

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9c78d0fb827d4b4fb92b0ae16be1f02c10065568
Parents
05bcb8a
Tree
59150c2

1 changed file

StatusFile+-
A docs/internal/repo-lifecycle.md 154 0
docs/internal/repo-lifecycle.mdadded
@@ -0,0 +1,154 @@
1
+# Repo lifecycle
2
+
3
+Post-creation a repo can be **renamed**, **transferred** to another
4
+user, **archived** (read-only), have its **visibility** flipped,
5
+**soft-deleted** (with a 7-day grace), and finally **hard-deleted** by
6
+a worker. Every transition is a function in `internal/repos/lifecycle/`;
7
+the corresponding handler in `internal/web/handlers/repo/lifecycle.go`
8
+runs the policy gate and dispatches.
9
+
10
+Each operation is described below with: who can do it, what state
11
+changes, what the recovery semantics are if a step partway through
12
+fails, and what audit row gets emitted.
13
+
14
+## Rename
15
+
16
+`POST /{owner}/{repo}/settings/rename` → `lifecycle.Rename`.
17
+
18
+* **Auth**: `repo:admin` action via S15 policy. Owner-only in V1
19
+  (collaborator admins can call it via the same gate; nothing in the
20
+  policy table is owner-specific).
21
+* **Rate limit**: 5 renames per 30 days. Tracked by counting redirect
22
+  rows for this repo within the window. Exceeding returns
23
+  `ErrRenameRateLimited` → HTTP 429.
24
+* **Steps**:
25
+  1. Validate the new name (regex, reserved list, lowercase).
26
+  2. Open a transaction.
27
+  3. INSERT `repo_redirects(old_owner_user_id, old_name, repo_id)`.
28
+  4. UPDATE `repos.name`. A unique-violation rolls back both → user
29
+     sees `ErrNameTaken` (409).
30
+  5. COMMIT.
31
+  6. `RepoFS.Move(/data/repos/sh/shithub/old.git, /data/repos/sh/shithub/new.git)`.
32
+* **FS-failure recovery**: a compensating UPDATE reverts `repos.name`
33
+  and DELETEs the redirect row, so persistent state remains
34
+  consistent. The user-facing error reflects the FS failure (which is
35
+  the truth — the move didn't happen).
36
+* **Audit**: `repo_renamed` with `{old_name, new_name}`.
37
+
38
+## Transfer
39
+
40
+* **Initiate**: `POST /{owner}/{repo}/settings/transfer` →
41
+  `lifecycle.RequestTransfer`. Sender confirms by typing the current
42
+  `owner/repo` slug. Inserts a `repo_transfer_requests` row with
43
+  `expires_at = now() + 7 days`, status `pending`.
44
+* **Accept**: `POST /transfers/{id}/accept` → `lifecycle.AcceptTransfer`.
45
+  The recipient is the actor. Atomic transaction:
46
+  1. `SELECT … FOR UPDATE` on the transfer row to serialize against
47
+     concurrent decline / cancel.
48
+  2. INSERT redirect from old owner+name.
49
+  3. UPDATE `repos.owner_user_id` to the recipient (name unchanged).
50
+  4. UPDATE the transfer row to status `accepted` with timestamp.
51
+  5. COMMIT.
52
+  6. `RepoFS.Move` from old owner's shard to recipient's shard. FS
53
+     failure here is logged but doesn't roll back — the DB is the
54
+     source of truth.
55
+* **Decline**: `POST /transfers/{id}/decline`. Recipient flips status
56
+  to `declined`. Repo state unchanged.
57
+* **Cancel**: `POST /transfers/{id}/cancel`. Repo admin (sender)
58
+  flips status to `canceled`. Recipient's accept attempts return
59
+  `ErrTransferTerminal` (409).
60
+* **Expire**: pending transfers past `expires_at` are flipped to
61
+  `expired` by the `lifecycle:sweep` worker job. Bulk operation; one
62
+  audit row would be noisy, so the row's terminal status is the audit.
63
+
64
+## Archive / Unarchive
65
+
66
+* `POST /{owner}/{repo}/settings/archive` → `is_archived=true`,
67
+  `archived_at=now()`. Does **not** move FS or change visibility.
68
+* `POST /{owner}/{repo}/settings/unarchive` → reverse.
69
+* **Push behavior**: enforced by S15's archived-repo deny in
70
+  `policy.Can` (`DenyArchived`). HTTP / SSH git transports both
71
+  surface the friendly "repository is archived; pushes are disabled"
72
+  message.
73
+
74
+## Visibility
75
+
76
+`POST /{owner}/{repo}/settings/visibility` →
77
+`lifecycle.SetVisibility(public|private)`. Idempotent on the same value.
78
+Changing public → private only stops *future* anonymous reads — already-
79
+fetched clones aren't recallable, that's git's nature. The UI surfaces
80
+this caveat explicitly.
81
+
82
+Existing **forks remain independent** — they're separate repos at the
83
+data layer; visibility flips don't cascade.
84
+
85
+## Soft delete + restore + hard delete
86
+
87
+* **Soft delete**: `POST /{owner}/{repo}/settings/delete` →
88
+  `lifecycle.SoftDelete`. Sets `deleted_at = now()`. Repo disappears
89
+  from listings, profile pinned slot, search. `/{owner}/{repo}` 404s
90
+  for non-owners (auth-aware via S15 policy's `DenyRepoDeleted`).
91
+* **Restore**: owner sees the soft-deleted repo at
92
+  `/settings/repositories`. POST to
93
+  `/settings/repositories/restore/{id}` clears `deleted_at`. Past the
94
+  7-day grace, restore returns `ErrPastGrace` (410).
95
+* **Hard delete**: `lifecycle:sweep` worker job (registered in
96
+  `cmd/shithubd/worker.go`) runs periodically. The handler:
97
+  1. `ListRepoIDsPastSoftDeleteGrace` — finds rows past 7 days.
98
+  2. For each, `lifecycle.HardDelete` runs:
99
+     * `OrphanForksOf(repoID)` — children's `fork_of_repo_id` set NULL.
100
+     * `DELETE FROM repos WHERE id=$1`. FK ON DELETE CASCADE handles
101
+       `push_events`, `repo_collaborators`, `repo_redirects` (rows
102
+       pointing at this repo), `repo_transfer_requests`, and
103
+       `webhook_events_pending`.
104
+     * `RemoveAll` the bare repo on disk.
105
+     * Audit `repo_hard_deleted` with the row snapshot in `meta`,
106
+       since the repo_id no longer resolves.
107
+  3. The same job also flips pending transfers past their TTL via
108
+     `ExpirePending`.
109
+
110
+## Redirect lookup on `/{owner}/{repo}`
111
+
112
+The `repoHome` handler now consults `repo_redirects` when
113
+`lookupRepoForViewer` returns ErrNoRows:
114
+
115
+* Hit → 301 to the canonical `/{currentOwner}/{currentName}` (with
116
+  any path tail preserved for future `/blob/x` and similar).
117
+* Miss → 404 as before.
118
+
119
+A redirect row pointing at a soft-deleted repo is treated as a miss
120
+to avoid 301 chains into 404s. When the hard-delete worker runs, the
121
+redirect row is removed by FK cascade and the URL transitions cleanly
122
+from "redirected" to "never existed."
123
+
124
+## Operational pointers
125
+
126
+* The `lifecycle:sweep` worker is enqueued ad-hoc today; S26 ships
127
+  cron scheduling. To run it manually:
128
+  ```sql
129
+  INSERT INTO jobs (kind, payload) VALUES ('lifecycle:sweep', '{}'::jsonb);
130
+  NOTIFY shithub_jobs;
131
+  ```
132
+* Soft-deleted repos take their disk path with them — the bare repo
133
+  on disk stays for the full grace window. If disk pressure becomes
134
+  an issue, configure the grace window down (it's a constant in
135
+  `lifecycle.go::softDeleteGrace`; promote to config in S37 if needed).
136
+* Renaming a repo doesn't require restarting any worker or hook.
137
+  The atomic FS move + DB update means the next hook invocation will
138
+  resolve the new path.
139
+
140
+## Audit actions emitted
141
+
142
+| Action                          | When                              |
143
+| ------------------------------- | --------------------------------- |
144
+| `repo_renamed`                  | rename completes                  |
145
+| `repo_archived`                 | archive                           |
146
+| `repo_unarchived`               | unarchive                         |
147
+| `repo_visibility_changed`       | flip                              |
148
+| `repo_soft_deleted`             | soft delete                       |
149
+| `repo_restored`                 | restore                           |
150
+| `repo_hard_deleted`             | hard-delete worker — meta carries snapshot |
151
+| `repo_transfer_requested`       | offer created                     |
152
+| `repo_transfer_accepted`        | recipient accepts                 |
153
+| `repo_transfer_declined`        | recipient declines                |
154
+| `repo_transfer_canceled`        | sender cancels                    |