user/org profiles/readmes/pins
6709221 · gmorell · 2026-05-14 03:59
Files changed
modified
PROGRESS.md
+392
−2
| 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 | 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 | 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