user/org profiles/readmes/pins

6709221 · gmorell · 2026-05-14 03:59

76 files +5325 -313

Files changed

modified PROGRESS.md
+392 −2
@@ -12,6 +12,383 @@ intermediate "I was here when I stopped" state) lives in the
12 12
13 13 ---
14 14
15 +## Slice 55 — Org members management
16 +
17 +New admin page to add / remove / promote / demote org members. Last-admin guard: the org can never end up without an admin.
18 +
19 +### Context
20 +
21 +- **`Organizations.list_members/1`** — preloads `:user`, orders admins first then by handle/email for stable display.
22 +- **`Organizations.set_member_role/3`** — validates `"admin" | "member"`, returns `{:ok, m}` (no-op on same-role), `{:error, :last_admin}` (demoting the only admin), `{:error, :not_a_member}`. Uses a small internal `admin_count/1` to gate.
23 +- **`Organizations.remove_member_guarded/2`** — wraps `remove_member/2` with the same last-admin check, returns `:ok | {:error, :last_admin | :not_a_member}`.
24 +
25 +### LiveView
26 +
27 +- **`OrgLive.Members`** at `/orgs/:handle/settings/members`. Admin-only; non-admins push-navigate back to the org page with a flash.
28 +- Add-member form: type a handle or email; resolves via `Accounts.get_user_by_handle/1` or a `Repo.get_by(User, email: …)` lookup. Adds as `member` (admins can promote afterwards).
29 +- Members list: avatar + display name + handle + email, role `<select>` per row (auto-submits on change), remove button. Inline flashes on guard rejections.
30 +- Nav link added to the org-show admin nav after "Profile".
31 +
32 +### Tests
33 +
34 +- **`org_members_test.exs`** — 9 tests: `list_members/1` ordering; `set_member_role/3` promote/demote, last-admin refusal, no-op same-role, not-a-member; `remove_member_guarded/2` member removal, last-admin refusal, removing one admin when another exists, non-member case. 501 tests pass overall.
35 +
36 +## Slice 54 — Org visibility + public-org anonymous access
37 +
38 +Orgs grow the same `public | internal | private` enum repos already had; the org's visibility caps what its repos can be; and public orgs no longer require login to view.
39 +
40 +### Schema
41 +
42 +- **Migration `20260513340000`**`organizations.visibility :string NOT NULL DEFAULT 'public'`, plus a CHECK constraint enforcing the three-value enum at the DB layer.
43 +- **`Organization.visibility_rank/1`** — public=0, internal=1, private=2. Higher rank = less public.
44 +- **`Organization.changeset/2`** + **`visibility_changeset/2`** validate inclusion in the enum.
45 +
46 +### Cascading enforcement
47 +
48 +- **`Repositories.create_repository_for_org/3`** now refuses repos more open than the org with `{:error, {:visibility_too_open, "<org_vis>"}}`. The check uses `Organization.visibility_rank/1` so the rule is a one-line comparison.
49 +- **`Repositories.update_repository/2`** runs the same check via `maybe_check_org_visibility/2` — passing the proposed visibility against the org's current setting.
50 +- **`Organizations.update_organization_visibility/2`** is the new path for changing the org's visibility. When *tightening* (e.g., public → internal), every repo currently more open than the new floor is bumped to match. Wrapped in a transaction; loosening leaves repos alone.
51 +
52 +### Anonymous access
53 +
54 +- `/orgs/:handle` moved out of `:authenticated_repo` and into the `:public_repo` live session — same pattern as `/r/:owner/:name`. The LV does the visibility gate:
55 + - **public** orgs: anyone (including anonymous) loads the page.
56 + - **internal** orgs: any signed-in user.
57 + - **private** orgs: only members (`Organizations.member?/2`).
58 + - Mismatches `push_navigate` to `/` with a flash.
59 +- The repo list shown on the org page is filtered by visibility for the viewer — non-members never see private repos even if the org itself is public.
60 +- **`Organizations.member?/2`** — new helper, parallel to `org_admin?/2`. Returns false for `nil` user.
61 +
62 +### UI
63 +
64 +- **`OrgLive.Index`** new-org form adds a Visibility select (3-option dropdown) with a one-line note that "repos can't be more open than the org."
65 +- **`OrgLive.SettingsProfile`** gets a "Visibility" section above the existing Public-profile section, with a select + Save button. Wired to `update_organization_visibility/2`.
66 +- **Description** (the existing top-level field that was previously only settable on creation) now editable from settings/profile alongside the rest, via an extended `Organization.profile_changeset/2`.
67 +
68 +### Tests
69 +
70 +- **`organization_visibility_test.exs`** — 8 tests: rank ordering; create rejects too-public repos for internal + private orgs; cascade bumps over-public repos when tightening; loosening doesn't touch repos; `member?/2` membership check.
71 +- **`org_live/show_visibility_test.exs`** — 4 tests: anonymous can view public org; anonymous bounces on internal; non-member bounces on private; member can view private.
72 +- **492 tests pass** overall.
73 +
74 +## Slice 53 — Registration modes + invite system
75 +
76 +Operators can gate who can sign up. Pattern ported from diogramos, extended from the binary (open / closed) shape into three explicit modes.
77 +
78 +### Config flag
79 +
80 +- **`config :git_gud, :registration_mode, :open | :invite_only | :closed`** (default `:open`).
81 +- `Accounts.registration_mode/0` accepts both atom and string values (so env-var-driven `:runtime.exs` config works the same) and falls back to `:open` for unknown values.
82 +- `Accounts.register_user/1` short-circuits with `{:error, :registration_closed}` when the mode is `:closed` — defense in depth so a direct API call doesn't bypass the LV gate.
83 +
84 +### Schema + context
85 +
86 +- **Migration `20260513330000`**`invites(token unique, note, created_by_id, consumed_by_id, expires_at, consumed_at, timestamps)`. Both FK refs `ON DELETE SET NULL`.
87 +- **`GitGud.Accounts.Invite`** schema with `create_changeset/2` (auto-generates a 24-byte URL-safe token if missing), `consume_changeset/2` (idempotent — re-consume is a no-op), and `active?/1` (true when not consumed and not past `expires_at`).
88 +- **`Accounts.create_invite/2`**, **`list_invites/1`**, **`get_active_invite/1`**, **`consume_invite/2`**, **`revoke_invite/2`**. Revoke is owner-only — `{:error, :forbidden}` for anyone else. Revoking stamps `consumed_at` without a `consumed_by_id`, distinguishing revoked from used.
89 +
90 +### Registration LV
91 +
92 +- Resolves a three-way `gate` assign on mount: `:closed`, `:invite_required`, or `:open`.
93 +- `:closed` shows a hard "registration is closed" alert and disables the form.
94 +- `:invite_required` shows the same alert with friendlier copy ("you'll need an invite link from an existing member").
95 +- `:open` with a valid `?invite=<token>` shows a welcome banner and proceeds. Invite is consumed on successful registration.
96 +- `:open` without an invite is the normal path.
97 +- `save` handlers reject submissions under `:closed` / `:invite_required` modes as a belt-and-braces guard.
98 +
99 +### Invites LV
100 +
101 +- **`/users/settings/invites`** — per-user invite management. Stream of the user's invites, "New invite" button creates one, copy-friendly URL rendered inline (`~p"/users/register?invite=#{token}"`), revoke button while active.
102 +- Mode-aware banner: open mode notes invites aren't required; closed mode warns that even invite links won't help right now.
103 +- Link entry added to `UserLive.Settings` ("Manage invites") alongside the other account-management entries.
104 +
105 +### Tests
106 +
107 +- **`invites_test.exs`** — 12 tests: registration mode resolution (default, atom, string, garbage), `register_user/1` enforcement of `:closed`, invite creation + uniqueness, `list_invites/1` ordering, `get_active_invite/1` for missing/active/consumed, idempotent consume, owner-only revoke, `Invite.active?/1` truth table. 480 tests pass overall.
108 +
109 +## Slice 52 — Org profile editor
110 +
111 +Symmetry with the user-side settings: orgs need their own editor for the slice-48 profile fields + slice-50 pinned repos. Schema was already there since slice 48; this slice ships the UI.
112 +
113 +### LiveView
114 +
115 +- **`OrgLive.SettingsProfile`** at `/orgs/:handle/settings/profile`. Admin-only — non-admins (including non-members) hit `push_navigate(/orgs/:handle)` with a flash.
116 +- Mirrors the structure of `UserLive.Settings`' profile section: same fields (display_name, bio, url, location, company, public_email, timezone), same `platform | url` socials textarea parsed into the JSONB shape, same pin-management UI (drag-to-reorder grid + add/remove form).
117 +- Pin-management uses the existing `PinReorder` hook + `PinnedReposGrid` component from slice 50; no new JS.
118 +- The "Pin" picker for orgs lists repos owned by the org (via `Repositories.list_repositories_for/1`).
119 +
120 +### Nav
121 +
122 +- The org-show page's admin nav (next to Secrets / Runners / Labels) grows a new "Profile" link as its first entry.
123 +
124 +### Tests
125 +
126 +- **`org_live/settings_profile_test.exs`** — 3 tests: non-admin is bounced (`:live_redirect`), admin sees the editor, admin can update profile fields including the socials block. 468 tests pass overall.
127 +
128 +## Slice 51 — Auto-created same-name profile README
129 +
130 +Fourth and final slice of the profile arc. A user `alice` gets an `alice/alice` repo auto-provisioned on signup; an org `acme` gets `acme/acme`. The repo's `README.md` (on the default branch) renders inside the profile page. Same pattern GitHub uses, no special-case storage — it's a regular repo, so all the usual editing, branching, federation, etc. just work.
131 +
132 +### Provisioning
133 +
134 +- **`Repositories.ensure_profile_repo/1`** — idempotent. For users, the repo is `<handle>/<handle>` owned by the user with no organization; for orgs, `<org-handle>/<org-handle>` is owned by the org with `owner_id` recorded as an admin for the audit trail. Visibility is `public`. The seed description is a one-line hint about what the repo is for.
135 +- **Hooked into `Accounts.register_user/1` and `Organizations.create_organization/2`** as best-effort. If the disk-side git init fails (stale tmp/, permission glitch, etc.), the error is logged and the surrounding action proceeds — registration shouldn't fail because the profile-repo wasn't initable.
136 +- The check for existing repos uses `from … where: owner_id == ^uid and is_nil(organization_id) and name == ^h` (rather than `Repo.get_by/2` with a nil keyword) because Ecto's `nil` comparison guardrail otherwise raises in production-mode queries.
137 +
138 +### Backfill
139 +
140 +- **`mix gitgud.profile_repos.backfill`** — walks every existing `users` + `organizations` row and calls `ensure_profile_repo/1`. Prints a created/skipped/failed summary at the end. Idempotent; safe to re-run.
141 +
142 +### README rendering
143 +
144 +- **`Repositories.get_profile_readme/2`** — locates the `<handle>/<handle>` repo, resolves its default branch, walks the tree, finds `readme(\.md)?` case-insensitively, fetches the blob bytes, and either renders markdown (when `.md`) or wraps as preformatted text. Returns `{:ok, html_string}` or `nil`.
145 +- Tolerant of every empty state: missing repo, missing default branch, missing README — all collapse to `nil` and the profile page just skips the section.
146 +
147 +### Profile rendering
148 +
149 +- **`UserLive.Profile`** + **`OrgLive.Show`** both load the README on mount and render it on the right column (below Pinned, above Repositories) in a `prose` article box. An "Edit in `<handle>/<handle>` →" deep-link shows for the self / admins.
150 +
151 +### Tests
152 +
153 +- **`profile_repo_test.exs`** — 7 tests: registration auto-creates the repo with the right owner/visibility/name, `ensure_profile_repo/1` is idempotent, registration doesn't fail when the repo already exists, org creation auto-creates the org repo, `get_profile_readme/2` returns nil when there's no profile repo, registered users get the seeded README, registered orgs get the seeded README.
154 +- One existing test in `profiles_test.exs` was updated to account for the now-auto-created profile repos showing up in `list_pinnable_for_user/1`'s output (asserts via subset / refute-membership rather than equality).
155 +- 465 tests pass overall.
156 +
157 +### Follow-up — seeded default README
158 +
159 +The auto-created repo is no longer empty. After the bare repo is initialized, `seed_profile_readme/2` stages a one-file initial commit:
160 +
161 +- **Write blob**: content goes through a tmp file + `git -C <path> hash-object -w <tmp>` (cleaner than wrestling Port-based stdin half-close from Erlang).
162 +- **Make tree**: `git -C <path> mktree < <tmp>` with one `100644 blob <sha>\tREADME.md` line, run through `sh -c` for stdin redirection.
163 +- **Commit**: reuses the existing `GitGud.Git.commit_tree/5` facade (CLI backend) with author/committer = the default `"GitGud" <noreply@gitgud.local>`. No parents.
164 +- **Ref**: `Git.update_ref/4` writes `refs/heads/<default_branch>` (typically `main`) at the new commit.
165 +
166 +The content differs slightly between users (`"Hi, I'm @alice 👋…"`) and orgs (`"# acme — This is the Org README…"`) so the placeholder reads sensibly out of the box. Best-effort: any plumbing failure is logged and the empty-repo state is preserved — registration still succeeds. Backfill works the same way for existing rows.
167 +
168 +## Slice 50 — Pinned repos with drag-to-reorder
169 +
170 +Third of the profile arc. Up to 7 pinned repos per user or org, manageable from settings and rendered as a card grid on the profile.
171 +
172 +### Schema
173 +
174 +- **Migration `20260513320000`**`pinned_repositories(id, owner_id, organization_id, repository_id, position, timestamps)`. CHECK constraint enforces XOR — exactly one of owner/organization must be set.
175 +- Partial unique indexes: `(owner_id, repository_id) WHERE owner_id IS NOT NULL` and the symmetric one for orgs, so the same repo can be pinned by both its user owner and an org but not twice on the same scope.
176 +- Partial composite indexes on `(owner_id, position)` and `(organization_id, position)` for the ordered-list query.
177 +
178 +### Context — `GitGud.Profiles`
179 +
180 +- **`list_pins/1`** — repository preloaded with `:owner, :organization`, ordered by `position asc`. Polymorphic on `%User{}` / `%Organization{}`.
181 +- **`pin/2`** — appends at the end (`position = current_count`). Returns `{:error, :max_reached}` when the limit (`max_pins/0` = 7) is hit. Duplicate pins surface as a unique-constraint changeset error from the partial index.
182 +- **`unpin/2`** — deletes the pin AND compacts remaining positions back to `0..n-1` so reorder math stays clean.
183 +- **`reorder/2`** — accepts a list of pin IDs (or strings — the JS hook sends strings). Validates each id belongs to the owner, dedupes, then writes the new `position` for each. Pins not in the list keep their relative order at the end, so partial reorders are safe. Wrapped in a transaction.
184 +
185 +### Drag-to-reorder hook
186 +
187 +- **`PinReorder`** in `assets/js/app.js`. No third-party dep — uses HTML5 `dragstart` / `dragover` / `drop`. The hook attaches itself to the grid container and wires every child with `data-pin-id={id}` for drag.
188 +- On drop, pushes `"reorder_pins"` with `%{"order" => [...ids in DOM order]}`. The LV handler delegates to `Profiles.reorder/2` and re-renders.
189 +- Picks "drop before" vs "drop after" based on the cursor's x position relative to the target card's midpoint — feels right for a horizontal row, still works fine when cards wrap to a new row.
190 +
191 +### Settings UI (user)
192 +
193 +- New "Pinned repositories" section at the top of `UserLive.Settings`. Renders the grid with `reorderable?={true}`, plus an explicit unpin button per row and a pick-a-repo select-and-Pin form below.
194 +- Add form is hidden when the cap is reached. Select shows only repos the user owns that aren't already pinned.
195 +
196 +### Profile rendering
197 +
198 +- **`GitGudWeb.PinnedReposGrid.grid/1`** — shared component. CSS grid: 1 col mobile, 2 sm, 3 lg. Each card: handle/name link, description (line-clamped), top language indicator (color swatch + name pulled from `git_commit_language_stats` for the HEAD of the repo's default branch).
199 +- Rendered on `UserLive.Profile` and `OrgLive.Show` (read-only, no `reorderable?`).
200 +
201 +### Tests
202 +
203 +- **`profiles_test.exs`** — 8 tests: append-at-end positions, cap at 7, duplicate-pin changeset error, unpin compacts, reorder applies + drops unknown ids, partial reorders preserve untouched relative order, string ids parse cleanly (mimics the JS payload shape), per-user scoping. 455 tests pass overall.
204 +
205 +### Follow-up — org-member pinning + removal cleanup
206 +
207 +- **`Repositories.list_pinnable_for_user/1`** — returns own repos + repos in any org the user is a member of, deduped. Settings UI swaps from `list_repositories_for/1` to this so members can show off org work on their personal profile.
208 +- **`pin` handler** in `UserLive.Settings` validated via `pinnable_by_user?/2` — own-repo + nil-org branch, OR org-repo with membership check. Mismatched picks (someone else's private repo, etc.) get a flash and no DB write.
209 +- **Pinnable `<option>` labels** include the namespace (`acme/site` not just `site`) so users disambiguate their pinned ord vs personal repos in the picker.
210 +- **`Profiles.unpin_org_repos/2`** — drops every pin a user has on repos belonging to a given org, then compacts positions.
211 +- **`Organizations.remove_member/2`** now calls that helper after the membership delete. Removed members stop advertising work they no longer have access to. Org-level pins (`organization_id` set) are unaffected — they belong to the org, not the removed user.
212 +- 3 new tests covering the cleanup (target user's pins go, other members' pins stay, owner's pins stay) and the expanded pinnable list. 458 tests pass overall.
213 +
214 +## Slice 49 — Theme-aware identicon avatars
215 +
216 +Second of the profile arc. No image storage, no upload pipeline yet — every user, org, and namespace gets a deterministic identicon-style avatar whose colors come from the active daisyUI theme.
217 +
218 +### Component
219 +
220 +- **`GitGudWeb.AvatarComponent.avatar/1`** — inline SVG, 5×5 grid, GitHub-identicon-style left/center cells mirrored on the right. Wrapped in a `rounded-full overflow-hidden` `<span>` so any future uploaded image drops in unchanged.
221 +- Imported into every LV via `git_gud_web.ex`'s `html_helpers/0`, so `<.avatar />` is available globally next to `<.icon />`.
222 +
223 +### Theme tracking
224 +
225 +- Cell `fill`s use `style="fill: var(--color-primary)"` (or `secondary`, `accent`, `info`, `success`, `warning` — picked from the seed's hash byte). Background is `bg-base-200` on the wrapper.
226 +- Switching daisyUI themes recolors every avatar live with zero re-render — the SVG references CSS variables, not baked-in hex.
227 +
228 +### Deterministic generation
229 +
230 +- SHA-256 of the seed name → 32 bytes. First byte mod palette length picks the foreground slot. Next 15 bits drive the cells: 5 rows × 3 left/center columns, each bit toggles its cell on/off. Left half + center is rendered; right half mirrors.
231 +- `render_spec/1` is pure — `{fg_color, [{x,y}…]}` — so it's trivially testable.
232 +
233 +### Sizes
234 +
235 +- Tailwind size classes — `xs (size-4)`, `sm (size-6)`, `md (size-8)`, `lg (size-12)`, `xl (size-16)`, `2xl (size-24)`. Two-character `viewBox` keeps the cells crisp at every size.
236 +
237 +### Wiring
238 +
239 +- **User profile** (`UserLive.Profile`) and **org profile** (`OrgLive.Show`) — `size="2xl"` next to the title.
240 +- **Repo header**`size="sm"` inline before the `{handle}` link, so every repo page (after slice 47) carries a tiny identifier of its namespace.
241 +- **Issue + PR comments**`size="xs"` before the author name.
242 +
243 +### Tests
244 +
245 +- **`avatar_component_test.exs`** — 9 tests: determinism per seed, divergence across seeds, palette membership, symmetric mirroring around x=2, in-grid coords, render emits SVG + rounded wrapper, fills always reference CSS vars (no hard-coded hex), size mapping, render stability. 447 tests pass overall.
246 +
247 +## Slice 48 — User + org profile pages
248 +
249 +First of four slices building out user/org profiles. This one ships the schema, the public profile page, the namespace-half link in the repo header, and the settings form.
250 +
251 +### Schema
252 +
253 +- **Migration `20260513310000`** — adds `handle` (citext, unique, NOT NULL) to `users`, plus the shared profile field block (`display_name`, `bio :text`, `url`, `location`, `company`, `public_email`, `timezone`, `socials :map`) to both `users` and `organizations`.
254 +- Existing rows get a handle backfilled from `lower(split_part(email, '@', 1))` with `_N` suffixes on collisions, all in a single SQL `UPDATE ... FROM (ranked CTE)`. So pre-existing users on dev/prod databases acquire URL-safe handles without anyone claiming one.
255 +
256 +### Accounts
257 +
258 +- **`User.email_changeset/3`** seeds `handle` automatically when missing (regex-gated; falls back to nil if the email local-part isn't URL-safe). Carries a `unique_constraint(:handle)` so insert collisions surface as a changeset error instead of a Postgres exception.
259 +- **`Accounts.register_user/1`** now retries up to 8 times with `_N` suffix on the handle if a duplicate collides — so signing up `alice@a.example` and `alice@b.example` produces `alice` and `alice_1` cleanly.
260 +- **`User.profile_changeset/2`** — separate cast that only touches the profile field set, with length caps on every string field.
261 +- **`Accounts.update_user_profile/2`** + **`get_user_by_handle/1`** — domain-level helpers.
262 +- **`User.display/1`** — common fallback chain `display_name → handle → email-local → "(unknown)"`.
263 +
264 +### Organizations
265 +
266 +- **`Organization.profile_changeset/2`** + **`Organizations.update_organization_profile/2`** + **`get_organization_by_handle/1`** (non-bang) + **`Organization.display/1`** — exact same shape as the user side.
267 +
268 +### Profile page
269 +
270 +- **New `UserLive.Profile`** at `/u/:handle`. Picked `/u/` to avoid colliding with `/users/log-in`, `/users/register`, `/users/settings/*` — Phoenix would otherwise route those into the profile LV.
271 +- Renders header (display name + `@handle`), profile detail block, repository list (filtered by visibility for non-self viewers), and an "Edit profile" button for the self.
272 +- **`OrgLive.Show`** extended to render the same profile detail block under its existing header.
273 +
274 +### Shared component
275 +
276 +- **`GitGudWeb.ProfileComponents.details/1`** — single component used by both profile pages. Takes a User or Organization struct (both carry the same fields) and renders bio + company + location + url + public_email + timezone + socials in a uniform `<dl>` block with heroicon row gutters.
277 +- **`social_items/1`** — tolerant parser of the `socials` JSONB shape (`%{"items" => [%{"platform","url","handle"}, …]}`). Empty / missing / malformed entries get filtered out silently.
278 +
279 +### Repo header: clickable namespace
280 +
281 +- The `{handle}/{name}` title in `RepoLive.Header.header/1` was a single repo-home link. Split into two — the handle half goes to `/u/:handle` for user-owned repos or `/orgs/:handle` for org-owned, the name half stays linked to the repo. The `/` separator stays dim.
282 +
283 +### Settings form
284 +
285 +- **`UserLive.Settings`** gains a "Public profile" section above the existing email/password/theme blocks. One simple input per field plus a `Social links` textarea where each line is `platform | url` — parsed into the JSONB shape on submit. Crude but functional editor for the v1; a proper repeating-row editor is a slice-49 follow-up if needed.
286 +
287 +### Storage handle resolution
288 +
289 +- `Storage.owner_handle/1` for a `User` now prefers `user.handle` (when present) and falls back to email-local — so existing tests that build users without explicitly setting handle still work.
290 +
291 +### Tests
292 +
293 +- **`profile_fields_test.exs`** — 9 tests: handle seeding from email, `_N` suffixing for collisions, case-insensitivity, `get_user_by_handle/1` missing → nil, `profile_changeset` cast scope + length caps, `User.display/1` fallback chain, org-side update + `Organization.display/1` chain. 438 tests pass overall.
294 +
295 +## Slice 46 — Clone-URL copy widget
296 +
297 +Tiny widget on the repo show page that lets a viewer grab the SSH or HTTPS clone URL without leaving the page. Inline by the ref-picker.
298 +
299 +### Layout (joined button group)
300 +
301 +```
302 +[ ▶ | url-text | HTTP | SSH | 📋 ]
303 + ▲ only at 2 ▲ select only at ≥1
304 + chevron rotates per state
305 +```
306 +
307 +Three cycle states driven by `cycle_clone_widget`:
308 +
309 +| State | Visible |
310 +|---|---|
311 +| 0 | chevron + HTTP + SSH (compact) |
312 +| 1 | + copy button |
313 +| 2 | + inline URL text + copy button |
314 +
315 +The chevron's `rotate-{0,90,180}` Tailwind class flips per state so the user has a visual cue of where they are. HTTP/SSH toggle the active transport — whichever is highlighted is what the copy button (and inline URL) reflects. The existing `set_transport` event handles the toggle.
316 +
317 +### Clipboard copy
318 +
319 +- The copy button fires `Phoenix.LiveView.JS.dispatch("git_gud:copy", detail: %{text: url})`. A small listener in `assets/js/app.js` reads `e.detail.text` and writes to `navigator.clipboard`, falling back to a hidden-textarea `execCommand("copy")` when the async clipboard API is unavailable (non-secure context, older browsers).
320 +- The dispatch chains a brief `scale-110` JS transition for tactile feedback.
321 +- No server round-trip for the copy itself — pure client-side. The LV stays responsible only for cycling state + toggling transport.
322 +
323 +### URL builders
324 +
325 +- Reused the existing `http_clone_url/2` and `ssh_clone_url/2` helpers that were already in `RepoLive.Show` powering the empty-repo onboarding panel. Same string output, so SSH-key prompts and the empty-state instructions stay consistent with what the widget shows.
326 +
327 +429 tests pass (no new tests — pure UI; existing show-page tests still cover render shape).
328 +
329 +## Slice 45 — Language stats bar + detail page
330 +
331 +Adds a tiny (0.3em) GitHub-style language strip to the repo show page and a `/stats/languages` detail view behind it. Stats are computed from existing PG caches (no extra git walk on the request path) and recomputed lazily via Oban.
332 +
333 +### Storage
334 +
335 +- **Migration `20260513290000`**`git_commit_language_stats(repository_id, sha, languages jsonb, total_bytes, file_count, computed_at)`. Pkey `(repository_id, sha)`; commit SHAs are immutable so this never goes stale.
336 +- `languages` is a `{"items" => [%{"language", "bytes", "files"}, …]}` map, sorted desc by bytes when written.
337 +
338 +### Compute path (no extra git CLI work)
339 +
340 +- **`Repositories.compute_and_store_language_stats/2`** walks the cached tree from `git_trees`, recurses into subtrees, and looks up each blob's `detected_language` + `size` from `git_blob_meta`. Binary blobs and unrecognized-language blobs are dropped. Bytes are summed per language; file counts kept alongside.
341 +- Both caches are populated naturally by `commit_backfill` on push (trees) and by the blob-view path (blob meta). So by the time a viewer opens the repo page after a push, the inputs are typically warm.
342 +- Write uses `on_conflict: {:replace, [...]}` on `(repository_id, sha)` so a re-run cleanly overwrites stale numbers if the underlying detection improves.
343 +
344 +### Worker
345 +
346 +- **`GitGud.Repositories.Workers.LanguageStats`** — Oban worker, `:git_cache` queue, max attempts 3. Unique on `(repository_id, sha_hex)` across available + scheduled + executing states with `period: :infinity` — duplicate enqueues collapse, the result is the same per immutable commit SHA.
347 +- **Enqueued from `CommitBackfill`** for every `refs/heads/*` update — the new HEAD's stats are computed in the background as part of the push pipeline.
348 +- **Also enqueued lazily** from `RepoLive.Show` and `RepoLive.Languages` when a viewer hits a commit that pre-dates this slice (or whose computation got dropped). Idempotent dedupe handles the race.
349 +
350 +### Read path
351 +
352 +- **`Repositories.get_language_stats/2`** — cache-only read. Returns `{:ok, stats}` or `:not_computed`. Never falls through to walking git on the request path; the bar simply doesn't render until the worker fills it in.
353 +- `RepoLive.Show.load_language_stats/2` calls this and, on `:not_computed`, enqueues the worker and returns `nil` so the bar stays hidden.
354 +
355 +### UI — the bar
356 +
357 +- New `language_bar/1` component on `RepoLive.Show`, rendered between the ref-picker and the file tree. 0.3em tall (≈4.8px at 16px base), `rounded-full`, segments colored from `GitGud.LanguageColors`.
358 +- Caps at **top 6 languages** plus an "Other" segment so very polyglot repos still render a usable bar. Each segment carries a `title="Language · %"` tooltip; the link's `title=` is a one-line summary of the top languages.
359 +- Whole bar is a `<.link navigate=...>` to the detail page.
360 +
361 +### UI — `/r/:owner/:name/stats/languages`
362 +
363 +- **New LiveView** `RepoLive.Languages`. Three states:
364 + - `:empty` — branch resolves to nothing (empty repo or unknown ref).
365 + - `:pending` — stats not computed yet; renders a "background job running, refresh shortly" notice. Enqueues the worker just like the show page.
366 + - `:ready` — renders a full-width 1em bar above a table: color swatch · language · percent · bytes · files. Totals row at the bottom; "Computed at" timestamp underneath.
367 +- Route nested under `/stats/...` so future stats pages (contributors, churn) can live alongside without renaming.
368 +
369 +### Language palette
370 +
371 +- **`GitGud.LanguageColors`** — curated map of ~55 GitHub-linguist hex colors for the languages Lumis detects. Unknown-language fallback hashes the name (SHA-256, mid-clamped channels) into a deterministic muted color so each unknown is at least consistent across pages. Nil/empty falls back to `#888888`.
372 +
373 +### Tests
374 +
375 +- **`repositories_language_stats_test.exs`** — 6 tests synthesizing PG cache rows directly (no git CLI). Covers bucketing by language sorted desc, recursion into subtrees, `on_conflict: replace` semantics for re-compute, persistence to `git_commit_language_stats`, and the `get_language_stats/2` read path including `:not_computed`.
376 +- **`language_colors_test.exs`** — 3 tests: known palette, hash-fallback determinism + hex shape, nil/empty fallback.
377 +- **419 tests pass** overall.
378 +
379 +### Detection fix + per-committer stats
380 +
381 +Two follow-ups in the same session:
382 +
383 +**Fix — language detection by filename.** The initial compute keyed off `git_blob_meta.detected_language`, but `Git.blob_meta/2` doesn't actually populate that field — every blob came back nil, so the bar showed `0 B / 0 files`. New `GitGud.Languages.detect/1` (extension + well-known basename map: Dockerfile, Makefile, mix.exs, etc.) is now called directly during the tree walk, using the filename already present on `git_trees.entries`. No per-blob lookup needed. Migration `20260513300000` truncates `git_commit_language_stats` so the next page load triggers a clean recompute. 4 new tests on `Languages.detect/1`.
384 +
385 +**Per-committer stats.** New `Repositories.committer_stats/1` runs a single GROUP BY over `git_commits` LEFT JOIN `git_commit_diffstats`, returning one row per `author_email` with commit count, summed insertions/deletions, and first/last commit dates. No table, no worker — computed at request time since the underlying caches are already populated on push by `CommitBackfill`. Authors are coalesced by email verbatim (.mailmap support deferred).
386 +
387 +- **`/r/:owner/:name/stats/committers`** new LiveView. Table: avatar swatch (deterministic hash of email) + initial · name+email · commits · +/− · first · last. Empty state when no commits are cached yet.
388 +- **Shared `RepoLive.StatsTabs.tabs/1`** — sibling tabs on both `/stats/languages` and `/stats/committers`. Both pages now share a "Stats" header with a per-page active tab, modeled on GitHub Insights.
389 +- 6 new tests on `committer_stats/1` (group-by author + sort, sum via diffstats join, commits without diffstats counted as zero churn, first/last span all authored_at, per-repo scoping, empty repo → []).
390 +- **429 tests pass** overall.
391 +
15 392 ## Slice 44 — Replace-with-mod-message moderation (+ undo)
16 393
17 394 Replaces the slice-42 hard-delete with a soft, reversible flow. Reported content gets its body swapped out for a moderator note; the original is preserved and revealable to the people who need to see it.
@@ -677,7 +1054,20 @@ Closed slices 14, 15, 16, 17 in this session. Final task graph:
677 1054 | 105 | done | Refresh README + landing page for slices 32–42 |
678 1055 | 106 | done | Lazy per-file diff expansion (slice 43) |
679 1056 | 107 | done | Replace-with-mod-message moderation (slice 44) |
680
681 End-of-session: 410 tests pass. Next likely: gix NIF fill-in for log/commit/tree/diff_stats/merge_base/merge_tree (10–100× perf for repo-page hot paths), or in-browser PR conflict resolution.
1057 +| 108 | done | Language stats bar + detail page (slice 45) |
1058 +| 109 | done | Language detection fix (filename-based) + per-committer stats (slice 45 follow-up) |
1059 +| 110 | done | Clone-URL copy widget on repo show (slice 46) |
1060 +| 111 | done | Shared header across repo pages (slice 47) |
1061 +| 112 | done | User + org profile pages (slice 48 — first of profile arc) |
1062 +| 113 | done | Theme-aware identicon avatars (slice 49) |
1063 +| 114 | done | Pinned repos with drag-to-reorder (slice 50) |
1064 +| 115 | done | Org-member pinning + removal cleanup (slice 50 follow-up) |
1065 +| 116 | done | Auto-created same-name profile README (slice 51 — profile arc complete) |
1066 +| 117 | done | Org profile editor (slice 52) |
1067 +| 118 | done | Registration modes + invite system (slice 53) |
1068 +| 119 | done | Org visibility + public-org anonymous access + description in settings (slice 54) |
1069 +| 120 | done | Org members management (slice 55) |
1070 +
1071 +End-of-session: 501 tests pass. Next pickups: gix NIF fill-in, in-browser PR conflict resolution, or whichever next feature lands.
682 1072 Embedded SSH daemon verified end-to-end against `git push --set-upstream origin master` from a real client (after fixing two bugs: spawning git with the dashed subcommand name failed; `:stderr_to_stdout` on the Port corrupted the pkt-line stream).
683 1073
modified assets/js/app.js
+82 −1
@@ -26,11 +26,92 @@ import {hooks as colocatedHooks} from "phoenix-colocated/git_gud"
26 26 import {WebauthnRegister, WebauthnAuthenticate} from "./webauthn"
27 27 import topbar from "../vendor/topbar"
28 28
29 +// Drag-to-reorder hook. The element it's attached to is the grid;
30 +// children with `data-pin-id="…"` are the draggable cards. On drop,
31 +// pushes "reorder_pins" with the new ordered list of ids.
32 +const PinReorder = {
33 + mounted() {
34 + this.dragId = null
35 + this.attach()
36 + },
37 + updated() {
38 + this.attach()
39 + },
40 + attach() {
41 + const cards = this.el.querySelectorAll("[data-pin-id]")
42 + cards.forEach(card => {
43 + if (card.dataset._dragWired === "1") return
44 + card.dataset._dragWired = "1"
45 + card.setAttribute("draggable", "true")
46 +
47 + card.addEventListener("dragstart", (e) => {
48 + this.dragId = card.dataset.pinId
49 + card.classList.add("opacity-50")
50 + e.dataTransfer.effectAllowed = "move"
51 + })
52 + card.addEventListener("dragend", () => {
53 + card.classList.remove("opacity-50")
54 + this.dragId = null
55 + })
56 + card.addEventListener("dragover", (e) => {
57 + e.preventDefault()
58 + e.dataTransfer.dropEffect = "move"
59 + })
60 + card.addEventListener("drop", (e) => {
61 + e.preventDefault()
62 + if (!this.dragId || this.dragId === card.dataset.pinId) return
63 +
64 + const from = this.el.querySelector(`[data-pin-id="${this.dragId}"]`)
65 + if (!from) return
66 +
67 + // Drop before the target if dragging right-to-left, after if left-to-right.
68 + const rect = card.getBoundingClientRect()
69 + const dropBefore = e.clientX < rect.left + rect.width / 2
70 + if (dropBefore) {
71 + this.el.insertBefore(from, card)
72 + } else {
73 + this.el.insertBefore(from, card.nextSibling)
74 + }
75 +
76 + const order = Array.from(this.el.querySelectorAll("[data-pin-id]"))
77 + .map(el => el.dataset.pinId)
78 + this.pushEvent("reorder_pins", {order})
79 + })
80 + })
81 + }
82 +}
83 +
29 84 const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
30 85 const liveSocket = new LiveSocket("/live", Socket, {
31 86 longPollFallbackMs: 2500,
32 87 params: {_csrf_token: csrfToken},
33 hooks: {...colocatedHooks, WebauthnRegister, WebauthnAuthenticate},
88 + hooks: {...colocatedHooks, WebauthnRegister, WebauthnAuthenticate, PinReorder},
89 +})
90 +
91 +// Clipboard copy via custom event. LiveViews fire it with:
92 +// JS.dispatch("git_gud:copy", detail: %{text: "..."})
93 +// Falls back to a temporary textarea + execCommand for browsers without
94 +// the async clipboard API (or non-secure contexts).
95 +window.addEventListener("git_gud:copy", (e) => {
96 + const text = (e.detail && e.detail.text) || ""
97 + if (!text) return
98 +
99 + const fallback = () => {
100 + const ta = document.createElement("textarea")
101 + ta.value = text
102 + ta.style.position = "fixed"
103 + ta.style.opacity = "0"
104 + document.body.appendChild(ta)
105 + ta.select()
106 + try { document.execCommand("copy") } catch (_) {}
107 + ta.remove()
108 + }
109 +
110 + if (navigator.clipboard && window.isSecureContext) {
111 + navigator.clipboard.writeText(text).catch(fallback)
112 + } else {
113 + fallback()
114 + }
34 115 })
35 116
36 117 // Show progress bar on live navigation and form submits
modified config/config.exs
+4 −1

Click to load diff…

modified lib/git_gud/accounts.ex
+146 −2

Click to load diff…

added lib/git_gud/accounts/invite.ex
+66 −0

Click to load diff…

modified lib/git_gud/accounts/user.ex
+72 −0

Click to load diff…

added lib/git_gud/languages.ex
+141 −0

Click to load diff…

modified lib/git_gud/organizations.ex
+181 −16

Click to load diff…

modified lib/git_gud/organizations/organization.ex
+60 −1

Click to load diff…

added lib/git_gud/profiles.ex
+188 −0

Click to load diff…

added lib/git_gud/profiles/pin.ex
+48 −0

Click to load diff…

modified lib/git_gud/repositories.ex
+407 −24

Click to load diff…

modified lib/git_gud/repositories/storage.ex
+2 −0

Click to load diff…

modified lib/git_gud_web.ex
+1 −0

Click to load diff…

added lib/git_gud_web/components/avatar_component.ex
+106 −0

Click to load diff…

modified lib/git_gud_web/live/branch_protection_live/index.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/live/deploy_key_live/index.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/live/interaction_policy_live/edit.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/live/issue_live/index.ex
+11 −6

Click to load diff…

modified lib/git_gud_web/live/issue_live/new.ex
+10 −4

Click to load diff…

modified lib/git_gud_web/live/issue_live/show.ex
+11 −1

Click to load diff…

modified lib/git_gud_web/live/label_live/index.ex
+11 −6

Click to load diff…

modified lib/git_gud_web/live/org_live/index.ex
+13 −0

Click to load diff…

added lib/git_gud_web/live/org_live/members.ex
+200 −0

Click to load diff…

added lib/git_gud_web/live/org_live/settings_profile.ex
+312 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+103 −17

Click to load diff…

added lib/git_gud_web/live/pinned_repos_grid.ex
+100 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/index.ex
+10 −6

Click to load diff…

modified lib/git_gud_web/live/pr_live/new.ex
+9 −3

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+11 −1

Click to load diff…

added lib/git_gud_web/live/profile_components.ex
+107 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/blob.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/commit.ex
+9 −0

Click to load diff…

added lib/git_gud_web/live/repo_live/committers.ex
+126 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/compare.ex
+10 −6

Click to load diff…

modified lib/git_gud_web/live/repo_live/fork.ex
+10 −6

Click to load diff…

modified lib/git_gud_web/live/repo_live/forks.ex
+10 −6

Click to load diff…

added lib/git_gud_web/live/repo_live/header.ex
+152 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/languages.ex
+22 −11

Click to load diff…

modified lib/git_gud_web/live/repo_live/log.ex
+9 −4

Click to load diff…

modified lib/git_gud_web/live/repo_live/packages.ex
+10 −9

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+13 −4

Click to load diff…

modified lib/git_gud_web/live/repo_live/show.ex
+89 −87

Click to load diff…

added lib/git_gud_web/live/repo_live/stats_tabs.ex
+36 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/tree.ex
+10 −0

Click to load diff…

modified lib/git_gud_web/live/secrets_live/repo.ex
+12 −3

Click to load diff…

added lib/git_gud_web/live/user_live/invites.ex
+136 −0

Click to load diff…

added lib/git_gud_web/live/user_live/profile.ex
+122 −0

Click to load diff…

modified lib/git_gud_web/live/user_live/registration.ex
+74 −17

Click to load diff…

modified lib/git_gud_web/live/user_live/settings.ex
+241 −0

Click to load diff…

modified lib/git_gud_web/live/webhook_live/index.ex
+12 −3

Click to load diff…

modified lib/git_gud_web/live/wiki_live/edit.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/live/wiki_live/index.ex
+10 −6

Click to load diff…

modified lib/git_gud_web/live/wiki_live/show.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/index.ex
+10 −6

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/router.ex
+6 −1

Click to load diff…

added lib/mix/tasks/gitgud.profile_repos.backfill.ex
+27 −0

Click to load diff…

added priv/repo/migrations/20260513300000_clear_stale_language_stats.exs
+16 −0

Click to load diff…

added priv/repo/migrations/20260513310000_add_profile_fields.exs
+84 −0

Click to load diff…

added priv/repo/migrations/20260513320000_create_pinned_repositories.exs
+34 −0

Click to load diff…

added priv/repo/migrations/20260513330000_create_invites.exs
+19 −0

Click to load diff…

added priv/repo/migrations/20260513340000_add_organization_visibility.exs
+13 −0

Click to load diff…

added test/git_gud/invites_test.exs
+141 −0

Click to load diff…

added test/git_gud/languages_test.exs
+32 −0

Click to load diff…

added test/git_gud/org_members_test.exs
+101 −0

Click to load diff…

added test/git_gud/organization_visibility_test.exs
+121 −0

Click to load diff…

added test/git_gud/profile_fields_test.exs
+104 −0

Click to load diff…

added test/git_gud/profile_repo_test.exs
+81 −0

Click to load diff…

added test/git_gud/profiles_test.exs
+219 −0

Click to load diff…

added test/git_gud/repositories_committer_stats_test.exs
+127 −0

Click to load diff…

modified test/git_gud/repositories_language_stats_test.exs
+17 −41

Click to load diff…

added test/git_gud_web/components/avatar_component_test.exs
+70 −0

Click to load diff…

added test/git_gud_web/live/org_live/settings_profile_test.exs
+57 −0

Click to load diff…

added test/git_gud_web/live/org_live/show_visibility_test.exs
+45 −0

Click to load diff…

Parents: a18a296