| @@ -0,0 +1,1306 @@ |
| 1 | # Profile about in a repository — implementation plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** Move the user/org profile about text out of the `users`/`orgs` |
| 6 | columns and into `profile/README.{md,org}` on the default branch of a |
| 7 | repository named `.gitbay` under the owner's namespace. |
| 8 | |
| 9 | **Architecture:** The about becomes a file, read the way wiki pages are |
| 10 | read — no store rows, access derived from the parent repository. The |
| 11 | control command's JSON keeps its `about`/`about_format` fields, so the |
| 12 | API and the iOS client do not move. Writes stop going through |
| 13 | `profile set`; the file is written by a push or `repo commit-file`. A |
| 14 | SQL migration parks the existing text in a holding table and drops the |
| 15 | columns; a `gitbayd admin` one-shot drains the table into repositories. |
| 16 | |
| 17 | **Tech Stack:** Go, SQLite (modernc.org/sqlite), `git` subprocesses via |
| 18 | `internal/gitutil`, `html/template`. |
| 19 | |
| 20 | **Spec:** `docs/specs/2026-09-19-profile-about-repo-design.md` |
| 21 | |
| 22 | ## Global Constraints |
| 23 | |
| 24 | - Repository name `.gitbay`; file `profile/README` plus one of `.md`, |
| 25 | `.org`, `.markdown`, resolved in that order. |
| 26 | - `ProfileOut.About` and `ProfileOut.AboutFormat` keep their JSON names. |
| 27 | `AboutFormat` is `org` for a `.org` file, `md` otherwise. |
| 28 | - A repository the caller cannot read yields an empty about, never an |
| 29 | error — the private-repo rule is 404-shaped, and a profile must not |
| 30 | confirm a namespace. |
| 31 | - Blob reads are capped at `maxCommitFileBytes` (1MB), already defined |
| 32 | in `internal/control/commitfile.go`. |
| 33 | - Never attribute anything to an assistant or model, anywhere. |
| 34 | - Commit messages reference the issue: `Ref #236`, and the last one |
| 35 | `Closes #236`. |
| 36 | - Branch is `profile-about-236`; never push to `main`. |
| 37 | |
| 38 | --- |
| 39 | |
| 40 | ### Task 1: Repository names may start with a dot |
| 41 | |
| 42 | `.gitbay` is an invalid repository name today: `namePat` requires a |
| 43 | leading alphanumeric. Relax it, and close the hole that lets a |
| 44 | repository be named exactly `.git`. |
| 45 | |
| 46 | **Files:** |
| 47 | - Modify: `internal/policy/names.go:37` (namePat), `:52-69` (ValidateName) |
| 48 | - Test: `internal/policy/names_test.go` |
| 49 | |
| 50 | **Interfaces:** |
| 51 | - Consumes: nothing. |
| 52 | - Produces: `policy.ValidateName(name string) error` accepts a single |
| 53 | leading dot. Task 6 relies on `.gitbay` validating. |
| 54 | |
| 55 | - [ ] **Step 1: Write the failing test** |
| 56 | |
| 57 | Append to `internal/policy/names_test.go`: |
| 58 | |
| 59 | ```go |
| 60 | func TestValidateNameLeadingDot(t *testing.T) { |
| 61 | for _, name := range []string{".gitbay", ".dotfiles", ".a"} { |
| 62 | if err := ValidateName(name); err != nil { |
| 63 | t.Errorf("ValidateName(%q) = %v, want nil", name, err) |
| 64 | } |
| 65 | } |
| 66 | for _, name := range []string{".", "..", ".git", "repo.git", "..a", ".-a"} { |
| 67 | if err := ValidateName(name); err == nil { |
| 68 | t.Errorf("ValidateName(%q) = nil, want error", name) |
| 69 | } |
| 70 | } |
| 71 | // The ceiling is 63 characters, dot included. |
| 72 | if err := ValidateName("." + strings.Repeat("a", 62)); err != nil { |
| 73 | t.Errorf("63-character dotted name rejected: %v", err) |
| 74 | } |
| 75 | if err := ValidateName("." + strings.Repeat("a", 63)); err == nil { |
| 76 | t.Error("64-character dotted name accepted") |
| 77 | } |
| 78 | } |
| 79 | ``` |
| 80 | |
| 81 | Add `"strings"` to that file's imports if it is not already there. |
| 82 | |
| 83 | - [ ] **Step 2: Run it and watch it fail** |
| 84 | |
| 85 | Run: `go test ./internal/policy/ -run TestValidateNameLeadingDot -v` |
| 86 | Expected: FAIL — `ValidateName(".gitbay")` returns an invalid-name error. |
| 87 | |
| 88 | - [ ] **Step 3: Relax the pattern** |
| 89 | |
| 90 | In `internal/policy/names.go`, replace the `namePat` declaration and its |
| 91 | comment: |
| 92 | |
| 93 | ```go |
| 94 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, |
| 95 | // dot, dash, underscore; must start with an alphanumeric, or with a single |
| 96 | // dot before one. A leading dot marks a repository as infrastructure rather |
| 97 | // than a project — .gitbay holds an owner's profile content. Dots are |
| 98 | // further restricted by ValidateName to avoid "." / ".." and ".git". |
| 99 | var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`) |
| 100 | ``` |
| 101 | |
| 102 | - [ ] **Step 4: Refuse `.git` exactly, not just as a suffix** |
| 103 | |
| 104 | In `ValidateName`, replace the suffix check: |
| 105 | |
| 106 | ```go |
| 107 | if len(name) > 4 && name[len(name)-4:] == ".git" { |
| 108 | return fmt.Errorf("invalid name %q: must not end in .git", name) |
| 109 | } |
| 110 | ``` |
| 111 | |
| 112 | with: |
| 113 | |
| 114 | ```go |
| 115 | // A name of exactly ".git" is now reachable through the leading-dot |
| 116 | // rule, and a bare .git directory in the namespace is not a thing to |
| 117 | // allow; HasSuffix covers both it and "repo.git". |
| 118 | if strings.HasSuffix(name, ".git") { |
| 119 | return fmt.Errorf("invalid name %q: must not end in .git", name) |
| 120 | } |
| 121 | ``` |
| 122 | |
| 123 | `strings` is already imported there. |
| 124 | |
| 125 | - [ ] **Step 5: Run the package's tests** |
| 126 | |
| 127 | Run: `go test ./internal/policy/` |
| 128 | Expected: PASS, including the pre-existing `TestValidateName`. |
| 129 | |
| 130 | - [ ] **Step 6: Commit** |
| 131 | |
| 132 | ```bash |
| 133 | git add internal/policy/names.go internal/policy/names_test.go |
| 134 | git commit -m "policy: a repository name may start with a dot |
| 135 | |
| 136 | Ref #236" |
| 137 | ``` |
| 138 | |
| 139 | --- |
| 140 | |
| 141 | ### Task 2: A first commit into an empty repository |
| 142 | |
| 143 | `CommitFileChange` resolves the branch and fails when it does not exist, |
| 144 | so committing the first file into a freshly created `.gitbay` is |
| 145 | impossible. Task 4's web button and Task 6's backfill both need it. |
| 146 | |
| 147 | Allow a root commit **only when the repository has no refs at all**, so |
| 148 | that a typo'd branch name in a repository with history still fails the |
| 149 | way it does today rather than silently starting an orphan branch. |
| 150 | |
| 151 | **Files:** |
| 152 | - Modify: `internal/gitutil/merge.go:188-239` (CommitFileChange) |
| 153 | - Test: `internal/gitutil/merge_test.go` (create if absent) |
| 154 | |
| 155 | **Interfaces:** |
| 156 | - Consumes: `gitutil.ResolveRef(dir, ref) (string, error)`, |
| 157 | `gitutil.CommitTree(dir, tree string, parents []string, name, email, message string) (string, error)`, |
| 158 | `gitutil.UpdateRefCAS(dir, ref, newSHA, oldSHA string) error`. |
| 159 | - Produces: `gitutil.CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error)` |
| 160 | — unchanged signature, now succeeding on an empty repository. |
| 161 | |
| 162 | - [ ] **Step 1: Write the failing test** |
| 163 | |
| 164 | Create or append to `internal/gitutil/merge_test.go`: |
| 165 | |
| 166 | ```go |
| 167 | func TestCommitFileChangeEmptyRepo(t *testing.T) { |
| 168 | dir := t.TempDir() |
| 169 | if err := InitBare(dir, "main", ""); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | sha, err := CommitFileChange(dir, "main", "profile/README.md", |
| 173 | []byte("# hello\n"), "alice", "alice@example.org", "add about") |
| 174 | if err != nil { |
| 175 | t.Fatalf("first commit into an empty repository: %v", err) |
| 176 | } |
| 177 | if sha == "" { |
| 178 | t.Fatal("no sha returned") |
| 179 | } |
| 180 | raw, err := ReadBlob(dir, "main", "profile/README.md", 1<<20) |
| 181 | if err != nil { |
| 182 | t.Fatalf("reading it back: %v", err) |
| 183 | } |
| 184 | if string(raw) != "# hello\n" { |
| 185 | t.Errorf("read back %q", raw) |
| 186 | } |
| 187 | // A second commit still takes the normal parented path. |
| 188 | if _, err := CommitFileChange(dir, "main", "profile/README.md", |
| 189 | []byte("# hello again\n"), "alice", "alice@example.org", "edit"); err != nil { |
| 190 | t.Fatalf("second commit: %v", err) |
| 191 | } |
| 192 | // A branch that does not exist in a repository that has history is |
| 193 | // still an error, not a new orphan branch. |
| 194 | if _, err := CommitFileChange(dir, "nope", "x.md", |
| 195 | []byte("x"), "alice", "alice@example.org", "x"); err == nil { |
| 196 | t.Error("committing to an unknown branch of a non-empty repository succeeded") |
| 197 | } |
| 198 | } |
| 199 | ``` |
| 200 | |
| 201 | Check `InitBare`'s signature in `internal/gitutil` before running; if |
| 202 | its third parameter is not an optional hooks directory, pass what the |
| 203 | existing callers in `internal/control/repo.go:217` pass. |
| 204 | |
| 205 | - [ ] **Step 2: Run it and watch it fail** |
| 206 | |
| 207 | Run: `go test ./internal/gitutil/ -run TestCommitFileChangeEmptyRepo -v` |
| 208 | Expected: FAIL — `branch main: unknown ref "refs/heads/main"`. |
| 209 | |
| 210 | - [ ] **Step 3: Add the unborn-branch path** |
| 211 | |
| 212 | In `internal/gitutil/merge.go`, replace the opening of |
| 213 | `CommitFileChange`: |
| 214 | |
| 215 | ```go |
| 216 | branchRef := "refs/heads/" + branch |
| 217 | parent, err := ResolveRef(dir, branchRef) |
| 218 | if err != nil { |
| 219 | return "", fmt.Errorf("branch %s: %w", branch, err) |
| 220 | } |
| 221 | ``` |
| 222 | |
| 223 | with: |
| 224 | |
| 225 | ```go |
| 226 | branchRef := "refs/heads/" + branch |
| 227 | parent, err := ResolveRef(dir, branchRef) |
| 228 | if err != nil { |
| 229 | // An unborn branch is only a root commit in a repository with no |
| 230 | // refs at all. Anywhere else an unresolvable branch is a typo, and |
| 231 | // starting an orphan branch for it would be worse than refusing. |
| 232 | if !isEmptyRepo(dir) { |
| 233 | return "", fmt.Errorf("branch %s: %w", branch, err) |
| 234 | } |
| 235 | parent = "" |
| 236 | } |
| 237 | ``` |
| 238 | |
| 239 | Replace the `read-tree` block: |
| 240 | |
| 241 | ```go |
| 242 | rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}") |
| 243 | rt.Env = env |
| 244 | if out, err := rt.CombinedOutput(); err != nil { |
| 245 | return "", fmt.Errorf("read-tree: %v\n%s", err, out) |
| 246 | } |
| 247 | ``` |
| 248 | |
| 249 | with: |
| 250 | |
| 251 | ```go |
| 252 | arg := parent + "^{tree}" |
| 253 | if parent == "" { |
| 254 | arg = "--empty" |
| 255 | } |
| 256 | rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", arg) |
| 257 | rt.Env = env |
| 258 | if out, err := rt.CombinedOutput(); err != nil { |
| 259 | return "", fmt.Errorf("read-tree: %v\n%s", err, out) |
| 260 | } |
| 261 | ``` |
| 262 | |
| 263 | Replace the `CommitTree` call: |
| 264 | |
| 265 | ```go |
| 266 | sha, err := CommitTree(dir, tree, []string{parent}, name, email, message) |
| 267 | ``` |
| 268 | |
| 269 | with: |
| 270 | |
| 271 | ```go |
| 272 | var parents []string |
| 273 | if parent != "" { |
| 274 | parents = []string{parent} |
| 275 | } |
| 276 | sha, err := CommitTree(dir, tree, parents, name, email, message) |
| 277 | ``` |
| 278 | |
| 279 | `UpdateRefCAS` already omits the old value when `parent` is empty, so |
| 280 | the tail of the function is unchanged. |
| 281 | |
| 282 | - [ ] **Step 4: Add the emptiness check** |
| 283 | |
| 284 | Add below `CommitFileChange` in the same file: |
| 285 | |
| 286 | ```go |
| 287 | // isEmptyRepo reports whether dir has no refs at all — a repository |
| 288 | // created but never pushed to. |
| 289 | func isEmptyRepo(dir string) bool { |
| 290 | out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output() |
| 291 | return err == nil && strings.TrimSpace(string(out)) == "" |
| 292 | } |
| 293 | ``` |
| 294 | |
| 295 | - [ ] **Step 5: Run the tests** |
| 296 | |
| 297 | Run: `go test ./internal/gitutil/` |
| 298 | Expected: PASS. |
| 299 | |
| 300 | - [ ] **Step 6: Commit** |
| 301 | |
| 302 | ```bash |
| 303 | git add internal/gitutil/merge.go internal/gitutil/merge_test.go |
| 304 | git commit -m "gitutil: commit-file writes the first commit of an empty repository |
| 305 | |
| 306 | Ref #236" |
| 307 | ``` |
| 308 | |
| 309 | --- |
| 310 | |
| 311 | ### Task 3: Read the about from the repository |
| 312 | |
| 313 | **Files:** |
| 314 | - Modify: `internal/control/profile.go` (constants, `ownerAbout`, `ProfileOut`, `runProfileShow`) |
| 315 | - Modify: `internal/httpd/web.go` (`aboutHTML`, the profile handler at ~446) |
| 316 | - Test: `e2e/profileabout_test.go` (create) |
| 317 | |
| 318 | **Interfaces:** |
| 319 | - Consumes: `control.RepoDir(root, owner, name) string`, |
| 320 | `gitutil.ReadBlob(dir, ref, path string, limit int64) ([]byte, error)`, |
| 321 | `policy.CanRead(u store.User, r store.Repo, grant string) bool`, |
| 322 | `c.Store.RepoByPath(path) (store.Repo, error)`, |
| 323 | `c.Store.AccessRole(repoID, userID int64) (string, error)`, |
| 324 | `maxCommitFileBytes` from `internal/control/commitfile.go`. |
| 325 | - Produces: |
| 326 | - `const ProfileRepoName = ".gitbay"` and `const AboutBase = "profile/README"` in `internal/control/profile.go` — Task 4, 5 and 6 use them. |
| 327 | - `func ownerAbout(c *Ctx, owner string) (text, format, path string)`. |
| 328 | - `ProfileOut.AboutPath string \`json:"about_path,omitempty"\`` — Task 4's template links to it. |
| 329 | - `func aboutHTML(text, format string) template.HTML` in `internal/httpd/web.go`. |
| 330 | |
| 331 | - [ ] **Step 1: Write the failing e2e test** |
| 332 | |
| 333 | Create `e2e/profileabout_test.go`: |
| 334 | |
| 335 | ```go |
| 336 | package e2e |
| 337 | |
| 338 | import ( |
| 339 | "strings" |
| 340 | "testing" |
| 341 | ) |
| 342 | |
| 343 | // The about text is a file in <owner>/.gitbay, read on every surface |
| 344 | // with the reader's own access. |
| 345 | func TestProfileAboutFromRepo(t *testing.T) { |
| 346 | inst := startInstance(t) |
| 347 | aliceKey := inst.newKey(t, "alice") |
| 348 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 349 | bobKey := inst.newKey(t, "bob") |
| 350 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") |
| 351 | |
| 352 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 { |
| 353 | t.Fatal("creating alice/.gitbay failed") |
| 354 | } |
| 355 | if _, _, code := inst.ssh(t, aliceKey, "# alice\n\nhello from a file\n", |
| 356 | "repo", "commit-file", "alice/.gitbay", "profile/README.md", |
| 357 | "--ref", "main", "--file", "-"); code != 0 { |
| 358 | t.Fatal("committing the about failed") |
| 359 | } |
| 360 | |
| 361 | out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") |
| 362 | if code != 0 { |
| 363 | t.Fatalf("profile show: %d", code) |
| 364 | } |
| 365 | if !strings.Contains(out, "hello from a file") { |
| 366 | t.Errorf("about not read from the repository: %s", out) |
| 367 | } |
| 368 | if !strings.Contains(out, `"about_format":"md"`) { |
| 369 | t.Errorf("about_format not md: %s", out) |
| 370 | } |
| 371 | if !strings.Contains(out, `"about_path":"profile/README.md"`) { |
| 372 | t.Errorf("about_path missing: %s", out) |
| 373 | } |
| 374 | |
| 375 | _, body := inst.get(t, "/alice") |
| 376 | if !strings.Contains(body, "hello from a file") { |
| 377 | t.Error("web profile does not render the about") |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | // .org wins nothing over .md, and a private .gitbay keeps the about to |
| 382 | // the people who can read it. |
| 383 | func TestProfileAboutFormatAndPrivacy(t *testing.T) { |
| 384 | inst := startInstance(t) |
| 385 | aliceKey := inst.newKey(t, "alice") |
| 386 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 387 | bobKey := inst.newKey(t, "bob") |
| 388 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") |
| 389 | |
| 390 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay", "--private") |
| 391 | inst.ssh(t, aliceKey, "* heading\n\norg text here\n", |
| 392 | "repo", "commit-file", "alice/.gitbay", "profile/README.org", |
| 393 | "--ref", "main", "--file", "-") |
| 394 | |
| 395 | out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 396 | if !strings.Contains(out, "org text here") || !strings.Contains(out, `"about_format":"org"`) { |
| 397 | t.Errorf("owner cannot read their own private about: %s", out) |
| 398 | } |
| 399 | out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") |
| 400 | if code != 0 { |
| 401 | t.Fatalf("profile show for an outsider should succeed: %d", code) |
| 402 | } |
| 403 | if strings.Contains(out, "org text here") { |
| 404 | t.Errorf("private about leaked to an outsider: %s", out) |
| 405 | } |
| 406 | |
| 407 | // A .md beside the .org wins: it is first in the resolution order. |
| 408 | inst.ssh(t, aliceKey, "markdown wins\n", |
| 409 | "repo", "commit-file", "alice/.gitbay", "profile/README.md", |
| 410 | "--ref", "main", "--file", "-") |
| 411 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 412 | if !strings.Contains(out, "markdown wins") { |
| 413 | t.Errorf(".md did not win resolution: %s", out) |
| 414 | } |
| 415 | } |
| 416 | ``` |
| 417 | |
| 418 | - [ ] **Step 2: Run them and watch them fail** |
| 419 | |
| 420 | Run: `go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m` |
| 421 | Expected: FAIL — `repo create alice/.gitbay` succeeds after Task 1, but |
| 422 | `profile show` reports no about, and `about_path` is absent. |
| 423 | |
| 424 | - [ ] **Step 3: Add the resolution helper** |
| 425 | |
| 426 | In `internal/control/profile.go`, after the `maxProfileLinks` constant: |
| 427 | |
| 428 | ```go |
| 429 | // ProfileRepoName is the repository that holds an owner's profile |
| 430 | // content. A dot-repo because it is infrastructure rather than a |
| 431 | // project: later per-owner configuration goes beside the about text, |
| 432 | // and the leading dot keeps it out of listings. |
| 433 | const ProfileRepoName = ".gitbay" |
| 434 | |
| 435 | // AboutBase is the about file's path in that repository, without its |
| 436 | // extension. |
| 437 | const AboutBase = "profile/README" |
| 438 | |
| 439 | // aboutExts are the formats the about is read from, in resolution |
| 440 | // order — the wiki's order, for the same reason. |
| 441 | var aboutExts = []string{".md", ".org", ".markdown"} |
| 442 | |
| 443 | // ownerAbout reads an owner's about text from <owner>/.gitbay. Anything |
| 444 | // missing — the repository, the branch, the file — is an empty about, |
| 445 | // and so is a repository this caller cannot read: a profile must not |
| 446 | // confirm a private namespace. path is the file it came from, so a |
| 447 | // client can link to it. |
| 448 | func ownerAbout(c *Ctx, owner string) (text, format, path string) { |
| 449 | repo, err := c.Store.RepoByPath(owner + "/" + ProfileRepoName) |
| 450 | if err != nil { |
| 451 | return "", "", "" |
| 452 | } |
| 453 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) |
| 454 | if err != nil || !policy.CanRead(c.User, repo, grant) { |
| 455 | return "", "", "" |
| 456 | } |
| 457 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 458 | for _, ext := range aboutExts { |
| 459 | raw, err := gitutil.ReadBlob(dir, repo.DefaultBranch, AboutBase+ext, maxCommitFileBytes) |
| 460 | if err != nil || len(raw) == 0 { |
| 461 | continue |
| 462 | } |
| 463 | f := "md" |
| 464 | if ext == ".org" { |
| 465 | f = "org" |
| 466 | } |
| 467 | return string(raw), f, AboutBase + ext |
| 468 | } |
| 469 | return "", "", "" |
| 470 | } |
| 471 | ``` |
| 472 | |
| 473 | - [ ] **Step 4: Add `AboutPath` and read through the helper** |
| 474 | |
| 475 | In `ProfileOut`, below `AboutFormat`: |
| 476 | |
| 477 | ```go |
| 478 | // AboutPath is where the about was read from in <owner>/.gitbay, so a |
| 479 | // client can link to the file rather than guess its extension. |
| 480 | AboutPath string `json:"about_path,omitempty"` |
| 481 | ``` |
| 482 | |
| 483 | In `runProfileShow`, replace the `d := ProfileOut{...}` literal: |
| 484 | |
| 485 | ```go |
| 486 | about, aboutFormat, aboutPath := ownerAbout(c, name) |
| 487 | d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, |
| 488 | About: about, AboutFormat: aboutFormat, AboutPath: aboutPath, |
| 489 | Links: p.Links, Repos: []ProfileRepo{}} |
| 490 | ``` |
| 491 | |
| 492 | - [ ] **Step 5: Render from the text, not from a store struct** |
| 493 | |
| 494 | In `internal/httpd/web.go`, replace `aboutHTML`: |
| 495 | |
| 496 | ```go |
| 497 | // aboutHTML renders a profile's about text. The format comes from the |
| 498 | // file it was read from: org is org, anything else markdown. |
| 499 | func aboutHTML(text, format string) template.HTML { |
| 500 | if strings.TrimSpace(text) == "" { |
| 501 | return "" |
| 502 | } |
| 503 | name := "about.md" |
| 504 | if format == "org" { |
| 505 | name = "about.org" |
| 506 | } |
| 507 | return renderReadme(name, []byte(text)) |
| 508 | } |
| 509 | ``` |
| 510 | |
| 511 | In the profile handler near line 446, drop the about from the |
| 512 | `store.Profile` literal: |
| 513 | |
| 514 | ```go |
| 515 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} |
| 516 | ``` |
| 517 | |
| 518 | and update the `AboutHTML` field's value in the `s.render` struct |
| 519 | literal to `aboutHTML(d.About, d.AboutFormat)`. Find its current call |
| 520 | with `grep -n 'aboutHTML' internal/httpd/web.go` and change that |
| 521 | argument list. |
| 522 | |
| 523 | - [ ] **Step 6: Build, vet, and run the tests** |
| 524 | |
| 525 | Run: `go build ./... && go vet ./... && go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m` |
| 526 | Expected: PASS. `go vet` matters here — `aboutHTML`'s signature changed |
| 527 | and `go build` does not compile `_test.go` callers. |
| 528 | |
| 529 | - [ ] **Step 7: Commit** |
| 530 | |
| 531 | ```bash |
| 532 | git add internal/control/profile.go internal/httpd/web.go e2e/profileabout_test.go |
| 533 | git commit -m "profile: read the about text from <owner>/.gitbay |
| 534 | |
| 535 | Ref #236" |
| 536 | ``` |
| 537 | |
| 538 | --- |
| 539 | |
| 540 | ### Task 4: Stop writing the about through `profile set` |
| 541 | |
| 542 | There is no about-specific write command, for the reason the wiki has |
| 543 | none: the content is a file, written the way files are written. |
| 544 | |
| 545 | **Files:** |
| 546 | - Modify: `internal/control/profile.go` (`register` usages, `profileEdit`, `parseProfileFlags`, `applyProfile`, `runProfileSet`, `runOrgProfile`) |
| 547 | - Modify: `internal/httpd/account.go:42-87` (page struct), `:251-266` (the `profile` form case) |
| 548 | - Modify: `internal/web/templates/account.html:25,34-36` |
| 549 | - Modify: `internal/httpd/routes.go` if a new form case needs no route (it does not — `/settings` already takes the POST) |
| 550 | - Test: `e2e/profileabout_test.go` (append) |
| 551 | |
| 552 | **Interfaces:** |
| 553 | - Consumes: `control.ProfileRepoName`, `control.AboutBase` from Task 3; |
| 554 | `ProfileOut.AboutPath` from Task 3; |
| 555 | `s.runControl(u store.User, argv []string) (int, string, bool)` and |
| 556 | `s.runControlStdin(u store.User, argv []string, stdin string) (string, bool)` |
| 557 | in `internal/httpd` — confirm their exact signatures with |
| 558 | `grep -n 'func (s \*Server) runControl' internal/httpd/*.go` before use. |
| 559 | - Produces: `profile set` and `org profile` with no `--about`, |
| 560 | `--about-format` or `--file`, and `ReadsStdin` unset. |
| 561 | |
| 562 | - [ ] **Step 1: Write the failing test** |
| 563 | |
| 564 | Append to `e2e/profileabout_test.go`: |
| 565 | |
| 566 | ```go |
| 567 | // The about is not settable through profile set any more: it is a file. |
| 568 | func TestProfileSetHasNoAbout(t *testing.T) { |
| 569 | inst := startInstance(t) |
| 570 | aliceKey := inst.newKey(t, "alice") |
| 571 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 572 | |
| 573 | _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "'inline text'") |
| 574 | if code == 0 { |
| 575 | t.Error("profile set --about still accepted") |
| 576 | } |
| 577 | // The flags that stay still work. |
| 578 | if _, _, code := inst.ssh(t, aliceKey, "", |
| 579 | "profile", "set", "--description", "'a line'", "--link", "'site|https://example.org'"); code != 0 { |
| 580 | t.Fatalf("profile set --description --link: %d", code) |
| 581 | } |
| 582 | out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 583 | if !strings.Contains(out, "a line") || !strings.Contains(out, "https://example.org") { |
| 584 | t.Errorf("description or link not saved: %s", out) |
| 585 | } |
| 586 | } |
| 587 | ``` |
| 588 | |
| 589 | - [ ] **Step 2: Run it and watch it fail** |
| 590 | |
| 591 | Run: `go test ./e2e/ -run TestProfileSetHasNoAbout -v -timeout 10m` |
| 592 | Expected: FAIL — `profile set --about` exits 0. |
| 593 | |
| 594 | - [ ] **Step 3: Drop the flags from the command registrations** |
| 595 | |
| 596 | In `internal/control/profile.go`'s `init`, replace the two |
| 597 | registrations: |
| 598 | |
| 599 | ```go |
| 600 | register(Command{Path: []string{"profile", "set"}, |
| 601 | Summary: "set your profile", |
| 602 | Usage: "profile set [--description <d>] [--website <url>] [--link <label|url>]... ('' clears)", Run: runProfileSet}) |
| 603 | register(Command{Path: []string{"org", "profile"}, |
| 604 | Summary: "show or set an org's profile", |
| 605 | Usage: "org profile <org> [--description <d>] [--website <url>] [--link <label|url>]...", Run: runOrgProfile}) |
| 606 | ``` |
| 607 | |
| 608 | `ReadsStdin` is gone from both; `TestStdinCommandsReadStdin` enforces |
| 609 | that a command that no longer reads stdin does not claim to. |
| 610 | |
| 611 | - [ ] **Step 4: Drop the fields from the edit struct and the parser** |
| 612 | |
| 613 | In `profileEdit`, delete the `About` and `AboutFormat` fields. Update |
| 614 | `empty()`: |
| 615 | |
| 616 | ```go |
| 617 | func (e profileEdit) empty() bool { |
| 618 | return e.Description == nil && e.Website == nil && e.Links == nil |
| 619 | } |
| 620 | ``` |
| 621 | |
| 622 | In `parseProfileFlags`, delete the `about`, `file` and `sawAbout` |
| 623 | locals, the `--about` / `--about-format` / `--file` entries from |
| 624 | `flagSpec.Values`, the `--about-format` case from the loop over the |
| 625 | value flags, the two `if f.Has(...)` blocks that set them, and the |
| 626 | `if sawAbout { ... bodyFrom ... }` block. The signature keeps its |
| 627 | `*Ctx` parameter — `parseFlags` errors still flow through `c` in the |
| 628 | callers — but if the compiler reports `c` unused, rename it to `_` in |
| 629 | the parameter list and update both call sites. |
| 630 | |
| 631 | In `applyProfile`, delete the `e.About` and `e.AboutFormat` blocks. |
| 632 | |
| 633 | In `runProfileSet`, change the "nothing to set" message: |
| 634 | |
| 635 | ```go |
| 636 | return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link") |
| 637 | ``` |
| 638 | |
| 639 | In both `runProfileSet` and `runOrgProfile`, drop `About:` and |
| 640 | `AboutFormat:` from the `ProfileOut` literals they emit. |
| 641 | |
| 642 | - [ ] **Step 5: Build and fix what falls out** |
| 643 | |
| 644 | Run: `go build ./... && go vet ./...` |
| 645 | Expected: errors in `internal/httpd/account.go` and possibly |
| 646 | `internal/control/migrate.go`. `internal/control/migrate.go` uses |
| 647 | `store.Profile` as a whole and needs no change until Task 6. Fix only |
| 648 | `account.go` here, per Step 6. |
| 649 | |
| 650 | - [ ] **Step 6: Point the account page at the file** |
| 651 | |
| 652 | In `internal/httpd/account.go`, in the `profile` case of the settings |
| 653 | form handler, replace the whole case body: |
| 654 | |
| 655 | ```go |
| 656 | case "profile": |
| 657 | argv := []string{"profile", "set", |
| 658 | "--description", r.FormValue("description"), |
| 659 | "--website", r.FormValue("website"), |
| 660 | } |
| 661 | for _, link := range profileLinkArgs(r.FormValue("links")) { |
| 662 | argv = append(argv, "--link", link) |
| 663 | } |
| 664 | if _, msg, ok := s.runControl(u, argv); !ok { |
| 665 | back(msg, "") |
| 666 | return |
| 667 | } |
| 668 | back("", "profile updated") |
| 669 | case "profile-repo": |
| 670 | // The about text is a file. Create the repository that holds it and |
| 671 | // commit a starter README, so the file editor has a branch to open. |
| 672 | path := u.Username + "/" + control.ProfileRepoName |
| 673 | if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok { |
| 674 | back(msg, "") |
| 675 | return |
| 676 | } |
| 677 | starter := "# " + u.Username + "\n\nThis is your profile's about text.\n" |
| 678 | if msg, ok := s.runControlStdin(u, |
| 679 | []string{"repo", "commit-file", path, control.AboutBase + ".md", |
| 680 | "--ref", "main", "--message", "add profile about", "--file", "-"}, starter); !ok { |
| 681 | back(msg, "") |
| 682 | return |
| 683 | } |
| 684 | back("", "profile repository created") |
| 685 | ``` |
| 686 | |
| 687 | `runControl`'s return shape is `(code int, msg string, ok bool)` in the |
| 688 | `theme` case above — match it exactly. In `accountPage`, add two fields to the anonymous page struct after |
| 689 | `LinksText`: |
| 690 | |
| 691 | ```go |
| 692 | AboutRepo string // "<user>/.gitbay", the repository that holds the about |
| 693 | AboutEdit string // the file editor's URL, empty when the repository has no about yet |
| 694 | ``` |
| 695 | |
| 696 | and compute them before `s.render`: |
| 697 | |
| 698 | ```go |
| 699 | aboutRepo := u.Username + "/" + control.ProfileRepoName |
| 700 | aboutEdit := "" |
| 701 | if profile.AboutPath != "" { |
| 702 | aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath |
| 703 | } |
| 704 | ``` |
| 705 | |
| 706 | then add `aboutRepo, aboutEdit` to the struct literal's value list in |
| 707 | the same position as the fields. |
| 708 | |
| 709 | - [ ] **Step 7: Replace the textarea with the pointer** |
| 710 | |
| 711 | In `internal/web/templates/account.html`, delete line 25 |
| 712 | (`{{if .Draft.Is "about"}}...{{end}}`), the About `<label>` and |
| 713 | `<textarea>`, and the `formatpicker` line. Replace the button group's |
| 714 | `{{template "previewbtn"}}` with nothing, leaving: |
| 715 | |
| 716 | ```html |
| 717 | <span class="btngroup"><button type="submit" class="btn">Save profile</button></span> |
| 718 | </form> |
| 719 | <p class="meta">Your about text is a file: <code>{{.AboutRepo}}</code> · |
| 720 | <code>profile/README.md</code>. {{if .AboutEdit}}<a href="{{.AboutEdit}}">Edit it</a>.{{else}} |
| 721 | It has no repository yet.{{end}}</p> |
| 722 | {{if not .AboutEdit}}<form method="post" action="/settings" class="setform"> |
| 723 | <input type="hidden" name="field" value="profile-repo"> |
| 724 | <button type="submit" class="btn">Create {{.AboutRepo}}</button> |
| 725 | </form>{{end}} |
| 726 | ``` |
| 727 | |
| 728 | - [ ] **Step 8: Run the tests** |
| 729 | |
| 730 | Run: `go build ./... && go vet ./... && go test ./internal/httpd/ ./internal/control/ && go test ./e2e/ -run 'TestProfile' -v -timeout 10m` |
| 731 | Expected: PASS. If `TestMainWidthClass` fails, a template was added — |
| 732 | it was not, so investigate rather than paper over it. |
| 733 | |
| 734 | - [ ] **Step 9: Commit** |
| 735 | |
| 736 | ```bash |
| 737 | git add internal/control/profile.go internal/httpd/account.go internal/web/templates/account.html e2e/profileabout_test.go |
| 738 | git commit -m "profile: the about text is written as a file, not a flag |
| 739 | |
| 740 | Ref #236" |
| 741 | ``` |
| 742 | |
| 743 | --- |
| 744 | |
| 745 | ### Task 5: Hide dot-repos from explore and profile listings |
| 746 | |
| 747 | Hiding the repository is what a dot-repo buys over `cmc/cmc`; without |
| 748 | this the move trades one visible single-purpose repository for another. |
| 749 | |
| 750 | **Files:** |
| 751 | - Modify: `internal/control/explore.go:54-` (the listing loop) |
| 752 | - Modify: `internal/control/profile.go` (`runProfileShow`'s repo loop) |
| 753 | - Test: `e2e/profileabout_test.go` (append) |
| 754 | |
| 755 | **Interfaces:** |
| 756 | - Consumes: `store.Repo.Name`. |
| 757 | - Produces: nothing new. |
| 758 | |
| 759 | - [ ] **Step 1: Write the failing test** |
| 760 | |
| 761 | Append to `e2e/profileabout_test.go`: |
| 762 | |
| 763 | ```go |
| 764 | // A dot-repo is infrastructure: it stays out of explore and off the |
| 765 | // profile's repository list, and stays in the owner's own inventory. |
| 766 | func TestDotReposHiddenFromListings(t *testing.T) { |
| 767 | inst := startInstance(t) |
| 768 | aliceKey := inst.newKey(t, "alice") |
| 769 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 770 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay") |
| 771 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/app") |
| 772 | |
| 773 | out, _, _ := inst.ssh(t, aliceKey, "", "explore", "--json") |
| 774 | if strings.Contains(out, ".gitbay") { |
| 775 | t.Errorf("dot-repo listed in explore: %s", out) |
| 776 | } |
| 777 | if !strings.Contains(out, "alice/app") { |
| 778 | t.Errorf("ordinary repo missing from explore: %s", out) |
| 779 | } |
| 780 | |
| 781 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 782 | if strings.Contains(out, `"path":"alice/.gitbay"`) { |
| 783 | t.Errorf("dot-repo listed on the profile: %s", out) |
| 784 | } |
| 785 | |
| 786 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "list", "--json") |
| 787 | if !strings.Contains(out, "alice/.gitbay") { |
| 788 | t.Errorf("dot-repo missing from the owner's own inventory: %s", out) |
| 789 | } |
| 790 | |
| 791 | // It is still reachable at its URL. |
| 792 | if status, _ := inst.get(t, "/alice/.gitbay"); status != 200 { |
| 793 | t.Errorf("dot-repo page returned %d", status) |
| 794 | } |
| 795 | } |
| 796 | ``` |
| 797 | |
| 798 | Check `inst.get`'s return shape against `e2e/commentmigrate_test.go` |
| 799 | (`_, body := inst.get(...)`) and adjust the status assertion to match. |
| 800 | |
| 801 | - [ ] **Step 2: Run it and watch it fail** |
| 802 | |
| 803 | Run: `go test ./e2e/ -run TestDotReposHiddenFromListings -v -timeout 10m` |
| 804 | Expected: FAIL — `.gitbay` appears in explore and on the profile. |
| 805 | |
| 806 | - [ ] **Step 3: Filter the two listings** |
| 807 | |
| 808 | In `internal/control/explore.go`, inside the `for _, repo := range repos` |
| 809 | loop, above the cursor check: |
| 810 | |
| 811 | ```go |
| 812 | // A dot-repo is infrastructure, not a project; .gitbay holds an |
| 813 | // owner's profile content and has nothing to explore. |
| 814 | if strings.HasPrefix(repo.Name, ".") { |
| 815 | continue |
| 816 | } |
| 817 | ``` |
| 818 | |
| 819 | Add `"strings"` to that file's imports if absent. |
| 820 | |
| 821 | In `internal/control/profile.go`, inside `runProfileShow`'s |
| 822 | `for _, repo := range all` loop, above the access check: |
| 823 | |
| 824 | ```go |
| 825 | if strings.HasPrefix(repo.Name, ".") { |
| 826 | continue |
| 827 | } |
| 828 | ``` |
| 829 | |
| 830 | - [ ] **Step 4: Run the tests** |
| 831 | |
| 832 | Run: `go build ./... && go test ./e2e/ -run 'TestProfile|TestDotRepos' -v -timeout 10m` |
| 833 | Expected: PASS. |
| 834 | |
| 835 | - [ ] **Step 5: Commit** |
| 836 | |
| 837 | ```bash |
| 838 | git add internal/control/explore.go internal/control/profile.go e2e/profileabout_test.go |
| 839 | git commit -m "explore, profile: dot-repos stay out of the listings |
| 840 | |
| 841 | Ref #236" |
| 842 | ``` |
| 843 | |
| 844 | --- |
| 845 | |
| 846 | ### Task 6: Migration and backfill |
| 847 | |
| 848 | A SQL migration cannot write git objects, and `gitbayd` runs |
| 849 | `MigrateUp` at startup — so a backfill that reads the columns must not |
| 850 | run after the migration that drops them. The migration parks the text |
| 851 | in a holding table; a one-shot drains it. |
| 852 | |
| 853 | **Files:** |
| 854 | - Create: `internal/store/migrations/0058_profile_about_out.up.sql` |
| 855 | - Create: `internal/store/migrations/0058_profile_about_out.down.sql` |
| 856 | - Create: `internal/store/aboutbackfill.go` |
| 857 | - Create: `cmd/gitbayd/adminabout.go` |
| 858 | - Modify: `internal/store/orgs.go:226-280` (`Profile`, `OwnerProfile`, `SetOwnerProfile`) |
| 859 | - Modify: `cmd/gitbayd/main.go:390-402` (register the command) |
| 860 | - Test: `e2e/aboutbackfill_test.go` (create) |
| 861 | |
| 862 | **Interfaces:** |
| 863 | - Consumes: `control.ProfileRepoName`, `control.AboutBase` (Task 4), |
| 864 | `control.RepoDir(root, owner, name) string`, |
| 865 | `gitutil.InitBare(dir, branch, hooksDir string) error`, |
| 866 | `gitutil.CommitFileChange(...)` (Task 2), |
| 867 | `store.CreateRepo(ownerKind string, ownerID int64, name, visibility string) (int64, error)`. |
| 868 | - Produces: |
| 869 | - `func (s *Store) PendingAboutBackfill() ([]AboutRow, error)` and |
| 870 | `func (s *Store) ClearAboutBackfill(kind string, id int64) error`, |
| 871 | with `type AboutRow struct { OwnerKind string; OwnerID int64; OwnerName string; About string; Format string }`. |
| 872 | - `gitbayd admin migrate-profile-about`. |
| 873 | |
| 874 | - [ ] **Step 1: Write the migration** |
| 875 | |
| 876 | `internal/store/migrations/0058_profile_about_out.up.sql`: |
| 877 | |
| 878 | ```sql |
| 879 | -- The about text moves into profile/README.* in <owner>/.gitbay. A SQL |
| 880 | -- migration cannot write git objects, so the text is parked here and |
| 881 | -- `gitbayd admin migrate-profile-about` drains the table into |
| 882 | -- repositories. A later release drops the emptied table. |
| 883 | CREATE TABLE profile_about_backfill ( |
| 884 | owner_kind TEXT NOT NULL, |
| 885 | owner_id INTEGER NOT NULL, |
| 886 | about TEXT NOT NULL, |
| 887 | about_format TEXT NOT NULL, |
| 888 | PRIMARY KEY (owner_kind, owner_id) |
| 889 | ); |
| 890 | |
| 891 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) |
| 892 | SELECT 'user', id, about, about_format FROM users WHERE about <> ''; |
| 893 | |
| 894 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) |
| 895 | SELECT 'org', id, about, about_format FROM orgs WHERE about <> ''; |
| 896 | |
| 897 | ALTER TABLE users DROP COLUMN about; |
| 898 | ALTER TABLE users DROP COLUMN about_format; |
| 899 | ALTER TABLE orgs DROP COLUMN about; |
| 900 | ALTER TABLE orgs DROP COLUMN about_format; |
| 901 | ``` |
| 902 | |
| 903 | `internal/store/migrations/0058_profile_about_out.down.sql`: |
| 904 | |
| 905 | ```sql |
| 906 | ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; |
| 907 | ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; |
| 908 | ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT ''; |
| 909 | ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; |
| 910 | |
| 911 | UPDATE users SET about = (SELECT about FROM profile_about_backfill |
| 912 | WHERE owner_kind = 'user' AND owner_id = users.id), |
| 913 | about_format = (SELECT about_format FROM profile_about_backfill |
| 914 | WHERE owner_kind = 'user' AND owner_id = users.id) |
| 915 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'user'); |
| 916 | |
| 917 | UPDATE orgs SET about = (SELECT about FROM profile_about_backfill |
| 918 | WHERE owner_kind = 'org' AND owner_id = orgs.id), |
| 919 | about_format = (SELECT about_format FROM profile_about_backfill |
| 920 | WHERE owner_kind = 'org' AND owner_id = orgs.id) |
| 921 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'org'); |
| 922 | |
| 923 | DROP TABLE profile_about_backfill; |
| 924 | ``` |
| 925 | |
| 926 | - [ ] **Step 2: Run the migration round-trip test** |
| 927 | |
| 928 | Run: `go test ./internal/store/ -run TestMigrateUpDown -v` |
| 929 | Expected: FAIL to compile — `OwnerProfile` still selects the dropped |
| 930 | columns. Proceed to Step 3, then re-run. |
| 931 | |
| 932 | - [ ] **Step 3: Take the about out of the store's profile** |
| 933 | |
| 934 | In `internal/store/orgs.go`, delete the `About` and `AboutFormat` fields |
| 935 | from `Profile`, and update its doc comment: |
| 936 | |
| 937 | ```go |
| 938 | // Profile is the presentational half of a user or org. The about text |
| 939 | // is not here: it is a file in <owner>/.gitbay, read through the |
| 940 | // control layer. |
| 941 | type Profile struct { |
| 942 | Description string `json:"description,omitempty"` |
| 943 | Website string `json:"website,omitempty"` |
| 944 | Links []ProfileLink `json:"links,omitempty"` |
| 945 | } |
| 946 | ``` |
| 947 | |
| 948 | In `OwnerProfile`, drop the two columns from the SELECT and the two |
| 949 | scan targets: |
| 950 | |
| 951 | ```go |
| 952 | err := s.DB.QueryRow( |
| 953 | "SELECT description, website, links FROM "+table+" WHERE id = ?", id). |
| 954 | Scan(&p.Description, &p.Website, &linksJSON) |
| 955 | ``` |
| 956 | |
| 957 | In `SetOwnerProfile`, drop the `AboutFormat` defaulting block and the |
| 958 | two columns from the UPDATE: |
| 959 | |
| 960 | ```go |
| 961 | _, err := s.DB.Exec( |
| 962 | "UPDATE "+table+" SET description = ?, website = ?, links = ? WHERE id = ?", |
| 963 | p.Description, p.Website, links, id) |
| 964 | ``` |
| 965 | |
| 966 | - [ ] **Step 4: Run build, vet and the store tests** |
| 967 | |
| 968 | Run: `go build ./... && go vet ./... && go test ./internal/store/` |
| 969 | Expected: PASS. `internal/control/migrate.go` embeds `store.Profile` in |
| 970 | its account bundle; the fields simply disappear from that JSON, and an |
| 971 | older bundle carrying them still imports because `encoding/json` ignores |
| 972 | unknown fields. No bundle version bump. |
| 973 | |
| 974 | - [ ] **Step 5: Write the failing backfill test** |
| 975 | |
| 976 | Create `e2e/aboutbackfill_test.go`: |
| 977 | |
| 978 | ```go |
| 979 | package e2e |
| 980 | |
| 981 | import ( |
| 982 | "path/filepath" |
| 983 | "strings" |
| 984 | "testing" |
| 985 | |
| 986 | "gitbay.org/gitbay/internal/store" |
| 987 | ) |
| 988 | |
| 989 | func TestMigrateProfileAbout(t *testing.T) { |
| 990 | inst := startInstance(t) |
| 991 | aliceKey := inst.newKey(t, "alice") |
| 992 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| 993 | |
| 994 | // Seed the holding table the way migration 0058 would have. |
| 995 | dbPath := filepath.Join(inst.root, "gitbay.db") |
| 996 | st, err := store.Open(dbPath) |
| 997 | if err != nil { |
| 998 | t.Fatal(err) |
| 999 | } |
| 1000 | _, err = st.DB.Exec( |
| 1001 | "INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) "+ |
| 1002 | "VALUES ('user', (SELECT id FROM users WHERE username='alice'), ?, 'org')", |
| 1003 | "* alice\n\ntext from the database\n") |
| 1004 | st.Close() |
| 1005 | if err != nil { |
| 1006 | t.Fatal(err) |
| 1007 | } |
| 1008 | |
| 1009 | inst.admin(t, "admin", "migrate-profile-about") |
| 1010 | |
| 1011 | out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 1012 | if code != 0 { |
| 1013 | t.Fatalf("profile show: %d", code) |
| 1014 | } |
| 1015 | if !strings.Contains(out, "text from the database") { |
| 1016 | t.Errorf("about not moved into the repository: %s", out) |
| 1017 | } |
| 1018 | if !strings.Contains(out, `"about_path":"profile/README.org"`) { |
| 1019 | t.Errorf("about not written at the recorded format: %s", out) |
| 1020 | } |
| 1021 | |
| 1022 | // Idempotent: a second run is a no-op and leaves the table empty. |
| 1023 | inst.admin(t, "admin", "migrate-profile-about") |
| 1024 | st, _ = store.Open(dbPath) |
| 1025 | var n int |
| 1026 | st.DB.QueryRow("SELECT count(*) FROM profile_about_backfill").Scan(&n) |
| 1027 | st.Close() |
| 1028 | if n != 0 { |
| 1029 | t.Errorf("holding table still has %d row(s)", n) |
| 1030 | } |
| 1031 | } |
| 1032 | ``` |
| 1033 | |
| 1034 | `inst.admin`'s first argument is the admin subcommand path as used in |
| 1035 | `e2e/commentmigrate_test.go` — confirm the exact call shape there |
| 1036 | (`inst.admin(t, "admin", "user", "create", ...)`) and match it. |
| 1037 | |
| 1038 | - [ ] **Step 6: Run it and watch it fail** |
| 1039 | |
| 1040 | Run: `go test ./e2e/ -run TestMigrateProfileAbout -v -timeout 10m` |
| 1041 | Expected: FAIL — no such subcommand. |
| 1042 | |
| 1043 | - [ ] **Step 7: Read and clear the holding table** |
| 1044 | |
| 1045 | Create `internal/store/aboutbackfill.go`: |
| 1046 | |
| 1047 | ```go |
| 1048 | package store |
| 1049 | |
| 1050 | // AboutRow is one owner's parked about text, waiting to become a file |
| 1051 | // in <owner>/.gitbay. Migration 0058 fills the table; the backfill |
| 1052 | // command drains it. |
| 1053 | type AboutRow struct { |
| 1054 | OwnerKind string |
| 1055 | OwnerID int64 |
| 1056 | OwnerName string |
| 1057 | About string |
| 1058 | Format string |
| 1059 | } |
| 1060 | |
| 1061 | // PendingAboutBackfill lists the owners whose about text has not been |
| 1062 | // written to a repository yet, resolving each one's name. |
| 1063 | func (s *Store) PendingAboutBackfill() ([]AboutRow, error) { |
| 1064 | rows, err := s.DB.Query(` |
| 1065 | SELECT b.owner_kind, b.owner_id, b.about, b.about_format, |
| 1066 | COALESCE(u.username, o.name) |
| 1067 | FROM profile_about_backfill b |
| 1068 | LEFT JOIN users u ON b.owner_kind = 'user' AND u.id = b.owner_id |
| 1069 | LEFT JOIN orgs o ON b.owner_kind = 'org' AND o.id = b.owner_id |
| 1070 | ORDER BY b.owner_kind, b.owner_id`) |
| 1071 | if err != nil { |
| 1072 | return nil, err |
| 1073 | } |
| 1074 | defer rows.Close() |
| 1075 | var out []AboutRow |
| 1076 | for rows.Next() { |
| 1077 | var r AboutRow |
| 1078 | var name *string |
| 1079 | if err := rows.Scan(&r.OwnerKind, &r.OwnerID, &r.About, &r.Format, &name); err != nil { |
| 1080 | return nil, err |
| 1081 | } |
| 1082 | if name == nil { |
| 1083 | continue // the owner is gone; the row goes with them |
| 1084 | } |
| 1085 | r.OwnerName = *name |
| 1086 | out = append(out, r) |
| 1087 | } |
| 1088 | return out, rows.Err() |
| 1089 | } |
| 1090 | |
| 1091 | // ClearAboutBackfill drops one owner's row once its file exists. |
| 1092 | func (s *Store) ClearAboutBackfill(kind string, id int64) error { |
| 1093 | _, err := s.DB.Exec( |
| 1094 | "DELETE FROM profile_about_backfill WHERE owner_kind = ? AND owner_id = ?", kind, id) |
| 1095 | return err |
| 1096 | } |
| 1097 | ``` |
| 1098 | |
| 1099 | - [ ] **Step 8: Write the one-shot** |
| 1100 | |
| 1101 | Create `cmd/gitbayd/adminabout.go`: |
| 1102 | |
| 1103 | ```go |
| 1104 | package main |
| 1105 | |
| 1106 | import ( |
| 1107 | "fmt" |
| 1108 | |
| 1109 | "github.com/spf13/cobra" |
| 1110 | |
| 1111 | "gitbay.org/gitbay/internal/config" |
| 1112 | "gitbay.org/gitbay/internal/control" |
| 1113 | "gitbay.org/gitbay/internal/gitutil" |
| 1114 | "gitbay.org/gitbay/internal/store" |
| 1115 | ) |
| 1116 | |
| 1117 | // adminMigrateProfileAboutCmd drains profile_about_backfill: each |
| 1118 | // owner's parked about text becomes profile/README.* in <owner>/.gitbay. |
| 1119 | // Idempotent — an owner who already has the file keeps it and loses the |
| 1120 | // row. |
| 1121 | func adminMigrateProfileAboutCmd() *cobra.Command { |
| 1122 | return &cobra.Command{ |
| 1123 | Use: "migrate-profile-about", |
| 1124 | Short: "write parked profile about text into each owner's .gitbay repository", |
| 1125 | RunE: func(cmd *cobra.Command, args []string) error { |
| 1126 | cfg, err := config.Load(configPath) |
| 1127 | if err != nil { |
| 1128 | return err |
| 1129 | } |
| 1130 | st, err := openStore(cfg) |
| 1131 | if err != nil { |
| 1132 | return err |
| 1133 | } |
| 1134 | defer st.Close() |
| 1135 | rows, err := st.PendingAboutBackfill() |
| 1136 | if err != nil { |
| 1137 | return err |
| 1138 | } |
| 1139 | n := 0 |
| 1140 | for _, row := range rows { |
| 1141 | written, err := writeAbout(cfg, st, row) |
| 1142 | if err != nil { |
| 1143 | return fmt.Errorf("%s: %w", row.OwnerName, err) |
| 1144 | } |
| 1145 | if err := st.ClearAboutBackfill(row.OwnerKind, row.OwnerID); err != nil { |
| 1146 | return err |
| 1147 | } |
| 1148 | if written { |
| 1149 | n++ |
| 1150 | } |
| 1151 | } |
| 1152 | fmt.Printf("wrote %d profile about file(s)\n", n) |
| 1153 | return nil |
| 1154 | }, |
| 1155 | } |
| 1156 | } |
| 1157 | |
| 1158 | // writeAbout creates <owner>/.gitbay if it does not exist and commits |
| 1159 | // the about at the recorded format. It reports whether it wrote |
| 1160 | // anything: an owner who already has the file is left alone. |
| 1161 | func writeAbout(cfg *config.Config, st *store.Store, row store.AboutRow) (bool, error) { |
| 1162 | path := row.OwnerName + "/" + control.ProfileRepoName |
| 1163 | repo, err := st.RepoByPath(path) |
| 1164 | if err != nil { |
| 1165 | id, cerr := st.CreateRepo(row.OwnerKind, row.OwnerID, control.ProfileRepoName, "public") |
| 1166 | if cerr != nil { |
| 1167 | return false, cerr |
| 1168 | } |
| 1169 | dir := control.RepoDir(cfg.Server.Root, row.OwnerName, control.ProfileRepoName) |
| 1170 | if ierr := gitutil.InitBare(dir, "main", control.HooksDir(cfg.Server.Root)); ierr != nil { |
| 1171 | st.DeleteRepo(id) |
| 1172 | return false, ierr |
| 1173 | } |
| 1174 | if repo, err = st.RepoByPath(path); err != nil { |
| 1175 | return false, err |
| 1176 | } |
| 1177 | } |
| 1178 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) |
| 1179 | ext := ".md" |
| 1180 | if row.Format == "org" { |
| 1181 | ext = ".org" |
| 1182 | } |
| 1183 | file := control.AboutBase + ext |
| 1184 | if _, err := gitutil.ReadBlob(dir, repo.DefaultBranch, file, 1); err == nil { |
| 1185 | return false, nil // already there |
| 1186 | } |
| 1187 | email := row.OwnerName + "@users.noreply." + cfg.SiteHost() |
| 1188 | if row.OwnerKind == "user" { |
| 1189 | if addr, _ := st.PrimaryVerifiedEmail(row.OwnerID); addr != "" { |
| 1190 | email = addr |
| 1191 | } |
| 1192 | } |
| 1193 | _, err = gitutil.CommitFileChange(dir, repo.DefaultBranch, file, |
| 1194 | []byte(row.About), row.OwnerName, email, "move profile about out of the database") |
| 1195 | return err == nil, err |
| 1196 | } |
| 1197 | ``` |
| 1198 | |
| 1199 | Confirm `cfg.SiteHost()`, `control.HooksDir`, `st.PrimaryVerifiedEmail` |
| 1200 | and `st.DeleteRepo` exist with those names: |
| 1201 | `grep -rn 'func.*SiteHost\|func HooksDir\|func (s \*Store) PrimaryVerifiedEmail\|func (s \*Store) DeleteRepo' internal/ | head`. |
| 1202 | Adjust the calls to what is actually there rather than adding shims. |
| 1203 | |
| 1204 | - [ ] **Step 9: Register the subcommand** |
| 1205 | |
| 1206 | In `cmd/gitbayd/main.go`, in the `admin.AddCommand(` list that already |
| 1207 | contains `adminMigrateCommitRefsCmd(),` (around line 401), add: |
| 1208 | |
| 1209 | ```go |
| 1210 | adminMigrateProfileAboutCmd(), |
| 1211 | ``` |
| 1212 | |
| 1213 | - [ ] **Step 10: Run the tests** |
| 1214 | |
| 1215 | Run: `go build ./... && go vet ./... && go test ./internal/store/ && go test ./e2e/ -run 'TestMigrateProfileAbout|TestProfile|TestDotRepos' -v -timeout 15m` |
| 1216 | Expected: PASS. |
| 1217 | |
| 1218 | - [ ] **Step 11: Commit** |
| 1219 | |
| 1220 | ```bash |
| 1221 | git add internal/store/migrations/0058_profile_about_out.up.sql \ |
| 1222 | internal/store/migrations/0058_profile_about_out.down.sql \ |
| 1223 | internal/store/aboutbackfill.go internal/store/orgs.go \ |
| 1224 | cmd/gitbayd/adminabout.go cmd/gitbayd/main.go \ |
| 1225 | e2e/aboutbackfill_test.go |
| 1226 | git commit -m "store: move the parked about text into each owner's .gitbay |
| 1227 | |
| 1228 | Ref #236" |
| 1229 | ``` |
| 1230 | |
| 1231 | --- |
| 1232 | |
| 1233 | ### Task 7: Documentation |
| 1234 | |
| 1235 | **Files:** |
| 1236 | - Modify: `.gitbay/wiki/Parity.md` (the profile row) |
| 1237 | - Modify: `.gitbay/wiki/Users.md` (the profile section) |
| 1238 | - Modify: `CHANGELOG.org` |
| 1239 | |
| 1240 | **Interfaces:** |
| 1241 | - Consumes: everything above. |
| 1242 | - Produces: nothing. |
| 1243 | |
| 1244 | - [ ] **Step 1: Find the rows to change** |
| 1245 | |
| 1246 | Run: `grep -n 'about\|profile' .gitbay/wiki/Parity.md .gitbay/wiki/Users.md | head -30` |
| 1247 | |
| 1248 | - [ ] **Step 2: Update Parity** |
| 1249 | |
| 1250 | The profile row's capability changes: the about is no longer a |
| 1251 | `profile set` flag. Add or amend a row reading that the about text is |
| 1252 | `profile/README.{md,org}` in `<owner>/.gitbay`, written by push or |
| 1253 | `repo commit-file`, readable on every surface. Keep the table's existing |
| 1254 | column order and marker vocabulary — read the surrounding rows first and |
| 1255 | match them. |
| 1256 | |
| 1257 | - [ ] **Step 3: Update Users** |
| 1258 | |
| 1259 | In the profile section, replace the `--about` documentation with where |
| 1260 | the file lives, the resolution order (`.md`, `.org`, `.markdown`), that |
| 1261 | a private `.gitbay` keeps the about private, and that `.gitbay` does not |
| 1262 | appear in explore or on the profile's repository list. Match the page's |
| 1263 | existing voice. |
| 1264 | |
| 1265 | - [ ] **Step 4: Update the changelog** |
| 1266 | |
| 1267 | Add an entry under the current unreleased heading in `CHANGELOG.org`, |
| 1268 | matching the file's existing entry style: |
| 1269 | |
| 1270 | ``` |
| 1271 | - Profile about text moved into =profile/README.{md,org}= on the default |
| 1272 | branch of =<owner>/.gitbay=. It is written by a push or |
| 1273 | =repo commit-file=; =profile set --about= is gone. Run |
| 1274 | =gitbayd admin migrate-profile-about= after upgrading to write each |
| 1275 | owner's existing text into their repository. Repository names may now |
| 1276 | start with a dot, and dot-repos stay out of explore and profile |
| 1277 | listings. |
| 1278 | ``` |
| 1279 | |
| 1280 | - [ ] **Step 5: Run the full local check** |
| 1281 | |
| 1282 | Run: `go build ./... && go vet ./... && go test ./internal/... ` |
| 1283 | Expected: PASS. The full e2e suite belongs to CI on bay1. |
| 1284 | |
| 1285 | - [ ] **Step 6: Commit** |
| 1286 | |
| 1287 | ```bash |
| 1288 | git add .gitbay/wiki/Parity.md .gitbay/wiki/Users.md CHANGELOG.org |
| 1289 | git commit -m "docs: the profile about text lives in a repository |
| 1290 | |
| 1291 | Closes #236" |
| 1292 | ``` |
| 1293 | |
| 1294 | --- |
| 1295 | |
| 1296 | ## Notes for whoever runs the deploy |
| 1297 | |
| 1298 | The migration and the backfill are one release but two steps. After |
| 1299 | `make deploy` has restarted `gitbayd` (which runs `MigrateUp`), run: |
| 1300 | |
| 1301 | ```bash |
| 1302 | ssh -p 2222 root@gitbay.org gitbayd admin migrate-profile-about |
| 1303 | ``` |
| 1304 | |
| 1305 | Until it runs, profiles that had an about show none. The holding table |
| 1306 | keeps the text, so nothing is lost in between. |