profile: the about text lives in <owner>/.gitbay !438
28 files changed, +2364 −190
Layout: unified · split
.gitbay/wiki/Parity.org +22 −9
| @@ -148,7 +148,7 @@ always markdown. | |||
| 148 | 148 | ||
| 149 | Every web form that takes markup has a Preview button beside its own | 149 | Every web form that takes markup has a Preview button beside its own |
| 150 | submit: issue and merge request create, their edit and comment boxes, | 150 | submit: issue and merge request create, their edit and comment boxes, |
| 151 | release create and edit, the profile about text, and the file editor on | 151 | release create and edit, and the file editor on |
| 152 | a path the forge renders. It posts to the form's own action, which | 152 | a path the forge renders. It posts to the form's own action, which |
| 153 | renders the draft and hands the page back without writing, so what you | 153 | renders the draft and hands the page back without writing, so what you |
| 154 | see is the rendering the thread will show, autolinks included. It is a | 154 | see is the rendering the thread will show, autolinks included. It is a |
| @@ -287,6 +287,7 @@ now render the way the reference does. go-org is not yet on the corpus. | |||
| 287 | | browse all public repositories | yes | yes | yes | | 287 | | browse all public repositories | yes | yes | yes | |
| 288 | | profile page | yes | yes | yes | | 288 | | profile page | yes | yes | yes | |
| 289 | | profile about and links | yes | yes | yes | | 289 | | profile about and links | yes | yes | yes | |
| 290 | | profile about as a file | yes | yes | yes | | ||
| 290 | | activity feed | yes | yes | yes | | 291 | | activity feed | yes | yes | yes | |
| 291 | | command reference | yes | no | n/a | | 292 | | command reference | yes | no | n/a | |
| 292 | 293 | ||
| @@ -300,8 +301,14 @@ operator — =c++=, =AND=, a lone quote — is a word to match and never a | |||
| 300 | syntax error. =repo grep= remains the per-repository file-contents | 301 | syntax error. =repo grep= remains the per-repository file-contents |
| 301 | search. | 302 | search. |
| 302 | 303 | ||
| 303 | About text renders as markdown or org-mode per the =about_format= it | 304 | The about text is =profile/README.{md,org,markdown}= on the default |
| 304 | was stored with. The iOS client decodes and renders both, | 305 | branch of =<owner>/.gitbay=, resolved in that order, so the extension |
| 306 | picks the renderer rather than a stored format. =profile show= reports | ||
| 307 | it as =about=, =about_format= and =about_path=. It reads with the | ||
| 308 | repository's own access, so a private =.gitbay= is a profile with no | ||
| 309 | about text to anyone but its owner and the admins. A repository whose | ||
| 310 | name starts with a dot stays out of =explore= and off the profile's | ||
| 311 | repository list. The iOS client decodes and renders both formats, | ||
| 305 | through the same OrgSwift path a README takes. | 312 | through the same OrgSwift path a README takes. |
| 306 | 313 | ||
| 307 | =help= lists the command registry. Bare it is an index, one line per | 314 | =help= lists the command registry. Bare it is an index, one line per |
| @@ -328,7 +335,7 @@ client has no use for one (krz/gitbay#57). | |||
| 328 | | API token mint | yes | no | no | | 335 | | API token mint | yes | no | no | |
| 329 | | account export bundle | yes | yes | n/a | | 336 | | account export bundle | yes | yes | n/a | |
| 330 | | profile set | yes | yes | yes | | 337 | | profile set | yes | yes | yes | |
| 331 | | preview profile about | n/a | yes | no | | 338 | | write the profile about | yes | yes | yes | |
| 332 | | request a login link | n/a | yes | n/a | | 339 | | request a login link | n/a | yes | n/a | |
| 333 | | account import bundle | yes | no | n/a | | 340 | | account import bundle | yes | no | n/a | |
| 334 | 341 | ||
| @@ -360,11 +367,17 @@ The account export bundle is =n/a= on iOS on the archive-download | |||
| 360 | argument above — a JSON bundle has nowhere useful to land on a phone, | 367 | argument above — a JSON bundle has nowhere useful to land on a phone, |
| 361 | and the web route stays the way to get one. | 368 | and the web route stays the way to get one. |
| 362 | 369 | ||
| 363 | =profile set= carries description, website, about and links; the JSON | 370 | =profile set= carries description, website and links; the JSON API runs |
| 364 | API runs it like any other write. The account settings page has the form, and so does the iOS client: | 371 | it like any other write. The account settings page has the form, and so |
| 365 | =--link= replaces the whole set rather than appending, so a client sends | 372 | does the iOS client: =--link= replaces the whole set rather than |
| 366 | every link it keeps on every save, and =--link ''= is how they are | 373 | appending, so a client sends every link it keeps on every save, and |
| 367 | cleared. | 374 | =--link ''= is how they are cleared. |
| 375 | |||
| 376 | The about text is not among those flags. It is a file, written by a push | ||
| 377 | or =repo commit-file= like any other file, which is why writing it is | ||
| 378 | yes on every surface: anything that can commit a file can write it. The | ||
| 379 | settings page points at the file and offers to create =<owner>/.gitbay= | ||
| 380 | when there is none. | ||
| 368 | 381 | ||
| 369 | * Administration | 382 | * Administration |
| 370 | 383 | ||
.gitbay/wiki/Users.org +25 −8
| @@ -142,14 +142,28 @@ gitbay profile show alice | |||
| 142 | gitbay profile set --description "builds small tools" --website https://alice.example | 142 | gitbay profile set --description "builds small tools" --website https://alice.example |
| 143 | #+end_src | 143 | #+end_src |
| 144 | 144 | ||
| 145 | About text is markdown by default, or org-mode. It takes inline text or | 145 | The about text is not a field. It is =profile/README.md= or |
| 146 | stdin, so it can live in a file you keep: | 146 | =profile/README.org= on the default branch of =<owner>/.gitbay=, a |
| 147 | repository you own like any other, written by a push or by | ||
| 148 | =repo commit-file=: | ||
| 147 | 149 | ||
| 148 | #+begin_src sh | 150 | #+begin_src sh |
| 149 | gitbay profile set --about "I maintain a few small tools." | 151 | gitbay repo create alice/.gitbay |
| 150 | gitbay profile set --file - --about-format org < about.org | 152 | gitbay repo commit-file alice/.gitbay profile/README.org \ |
| 153 | --ref main --file - < about.org | ||
| 151 | #+end_src | 154 | #+end_src |
| 152 | 155 | ||
| 156 | The extension picks the renderer; =.md=, =.org= and =.markdown= are | ||
| 157 | resolved in that order, so a =README.md= beside a =README.org= wins. | ||
| 158 | =profile show= reports the text as =about=, its format as | ||
| 159 | =about_format=, and the file it came from as =about_path=. | ||
| 160 | |||
| 161 | Access follows the repository: a private =.gitbay= keeps the about text | ||
| 162 | to you and the admins. A repository whose name starts with a dot is | ||
| 163 | infrastructure rather than a project, so it stays out of =explore= and | ||
| 164 | off the profile's repository list — =repo list= still shows it, and it | ||
| 165 | is reachable at its own URL. | ||
| 166 | |||
| 153 | Up to five links, each =label|url= or a bare url, http(s) only. Passing | 167 | Up to five links, each =label|url= or a bare url, http(s) only. Passing |
| 154 | =--link= replaces the whole set; a single empty one clears it: | 168 | =--link= replaces the whole set; a single empty one clears it: |
| 155 | 169 | ||
| @@ -164,13 +178,16 @@ leave out is untouched, and ='' clears the one you name. Org profiles | |||
| 164 | work the same way and need org admin: | 178 | work the same way and need org admin: |
| 165 | 179 | ||
| 166 | #+begin_src sh | 180 | #+begin_src sh |
| 167 | gitbay org profile krz --description "software and experiments" --about-format org --file - < krz.org | 181 | gitbay org profile krz --description "software and experiments" |
| 168 | gitbay org profile krz # no flags shows it | 182 | gitbay org profile krz # no flags shows it |
| 169 | #+end_src | 183 | #+end_src |
| 170 | 184 | ||
| 171 | The web renders profiles but has no form for editing one, so the CLI is | 185 | An org's about text works the same way, in =<org>/.gitbay=. |
| 172 | the only interface today. The JSON API runs =profile set= like any | 186 | |
| 173 | other write command. | 187 | The settings page edits description, website and links, and points at the |
| 188 | about file — with a button that creates =<owner>/.gitbay= and its first | ||
| 189 | README when you have none, so the file editor has a branch to open. The | ||
| 190 | JSON API runs =profile set= like any other write command. | ||
| 174 | 191 | ||
| 175 | * Repositories | 192 | * Repositories |
| 176 | 193 | ||
CHANGELOG.org +25
| @@ -44,6 +44,31 @@ sixteen-page one never covered (#226): | |||
| 44 | crumb, a heading that names an owner, an empty-state note. Link and | 44 | crumb, a heading that names an owner, an empty-state note. Link and |
| 45 | muted text are 1.07:1 apart in dark. | 45 | muted text are 1.07:1 apart in dark. |
| 46 | 46 | ||
| 47 | The profile about text is a file (#236). | ||
| 48 | |||
| 49 | *Upgrade note.* Run =gitbayd admin migrate-profile-about= after | ||
| 50 | upgrading. Migration 0058 parks each owner's about text in a holding | ||
| 51 | table and drops the columns; that command writes it into a public | ||
| 52 | =<owner>/.gitbay=. Until it runs, profiles that had an about show none. | ||
| 53 | Nothing is lost in between — the table keeps the text. | ||
| 54 | |||
| 55 | - The about text on a profile is =profile/README.md= or | ||
| 56 | =profile/README.org= on the default branch of =<owner>/.gitbay=, | ||
| 57 | read with that repository's own access. The extension picks the | ||
| 58 | renderer; =.md=, =.org= and =.markdown= resolve in that order. | ||
| 59 | =profile show= gains =about_path=, the file the text came from. | ||
| 60 | - =profile set= and =org profile= lose =--about=, =--about-format= and | ||
| 61 | =--file=. The about is written by a push or =repo commit-file=, the | ||
| 62 | way a wiki page is. The settings page points at the file and offers | ||
| 63 | to create the repository that holds it. | ||
| 64 | - Repository names may start with a dot; owner names may not. A | ||
| 65 | repository whose name starts with a dot stays out of =explore= and | ||
| 66 | off the profile's repository list, and remains in =repo list= and at | ||
| 67 | its own URL. | ||
| 68 | - =repo commit-file= writes the first commit of a repository that has | ||
| 69 | never been pushed to. An unresolvable branch is still an error | ||
| 70 | anywhere the repository already has refs. | ||
| 71 | |||
| 47 | * v1.30.0 — 2026-09-19 | 72 | * v1.30.0 — 2026-09-19 |
| 48 | 73 | ||
| 49 | Every command runs on every surface, markup previews before it is | 74 | Every command runs on every surface, markup previews before it is |
cmd/gitbay/main.go +4 −4
| @@ -102,8 +102,8 @@ func newRoot() *cobra.Command { | |||
| 102 | orgCmd(), | 102 | orgCmd(), |
| 103 | group("profile", "user and org profiles", | 103 | group("profile", "user and org profiles", |
| 104 | pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}), | 104 | pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}), |
| 105 | pass("set", "set your profile: [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...", | 105 | pass("set", "set your profile: [--description d] [--website url] [--link label|url]...", |
| 106 | passOpts{server: []string{"profile", "set"}, stdinOK: true}), | 106 | passOpts{server: []string{"profile", "set"}}), |
| 107 | ), | 107 | ), |
| 108 | webhookCmd(), | 108 | webhookCmd(), |
| 109 | remoteCmd(), | 109 | remoteCmd(), |
| @@ -671,8 +671,8 @@ func orgCmd() *cobra.Command { | |||
| 671 | pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), | 671 | pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), |
| 672 | pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), | 672 | pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), |
| 673 | pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), | 673 | pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), |
| 674 | pass("profile", "show or set an org profile: <org> [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...", | 674 | pass("profile", "show or set an org profile: <org> [--description d] [--website url] [--link label|url]...", |
| 675 | passOpts{server: []string{"org", "profile"}, stdinOK: true}), | 675 | passOpts{server: []string{"org", "profile"}}), |
| 676 | group("members", "manage members", | 676 | group("members", "manage members", |
| 677 | pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), | 677 | pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), |
| 678 | pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), | 678 | pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), |
cmd/gitbayd/adminabout.go added +98
| @@ -0,0 +1,98 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | |||
| 6 | "github.com/spf13/cobra" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/config" | ||
| 9 | "gitbay.org/gitbay/internal/control" | ||
| 10 | "gitbay.org/gitbay/internal/gitutil" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // adminMigrateProfileAboutCmd drains profile_about_backfill: each owner's | ||
| 15 | // parked about text becomes profile/README.* in <owner>/.gitbay. Idempotent | ||
| 16 | // — an owner who already has the file keeps it and loses the row. | ||
| 17 | func adminMigrateProfileAboutCmd() *cobra.Command { | ||
| 18 | return &cobra.Command{ | ||
| 19 | Use: "migrate-profile-about", | ||
| 20 | Short: "write parked profile about text into each owner's .gitbay repository", | ||
| 21 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 22 | cfg, err := config.Load(configPath) | ||
| 23 | if err != nil { | ||
| 24 | return err | ||
| 25 | } | ||
| 26 | st, err := openStore(cfg) | ||
| 27 | if err != nil { | ||
| 28 | return err | ||
| 29 | } | ||
| 30 | defer st.Close() | ||
| 31 | rows, err := st.PendingAboutBackfill() | ||
| 32 | if err != nil { | ||
| 33 | return err | ||
| 34 | } | ||
| 35 | n := 0 | ||
| 36 | for _, row := range rows { | ||
| 37 | written, err := writeAbout(cfg, st, row) | ||
| 38 | if err != nil { | ||
| 39 | return fmt.Errorf("%s: %w", row.OwnerName, err) | ||
| 40 | } | ||
| 41 | if err := st.ClearAboutBackfill(row.OwnerKind, row.OwnerID); err != nil { | ||
| 42 | return err | ||
| 43 | } | ||
| 44 | if written { | ||
| 45 | n++ | ||
| 46 | } | ||
| 47 | } | ||
| 48 | fmt.Printf("wrote %d profile about file(s)\n", n) | ||
| 49 | return nil | ||
| 50 | }, | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | // writeAbout creates <owner>/.gitbay if it does not exist and commits the | ||
| 55 | // about at the recorded format. It reports whether it wrote anything: an | ||
| 56 | // owner who already has the file is left alone. | ||
| 57 | func writeAbout(cfg config.Config, st *store.Store, row store.AboutRow) (bool, error) { | ||
| 58 | path := row.OwnerName + "/" + control.ProfileRepoName | ||
| 59 | repo, err := st.RepoByPath(path) | ||
| 60 | if err != nil { | ||
| 61 | // Public, because the about it carries was public where it was. | ||
| 62 | id, cerr := st.CreateRepo(row.OwnerKind, row.OwnerID, control.ProfileRepoName, "public") | ||
| 63 | if cerr != nil { | ||
| 64 | return false, cerr | ||
| 65 | } | ||
| 66 | dir := control.RepoDir(cfg.Server.Root, row.OwnerName, control.ProfileRepoName) | ||
| 67 | if ierr := gitutil.InitBare(dir, "main", control.HooksDir(cfg.Server.Root)); ierr != nil { | ||
| 68 | st.DeleteRepo(id) | ||
| 69 | return false, ierr | ||
| 70 | } | ||
| 71 | if repo, err = st.RepoByPath(path); err != nil { | ||
| 72 | return false, err | ||
| 73 | } | ||
| 74 | } | ||
| 75 | dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 76 | ext := ".md" | ||
| 77 | if row.Format == "org" { | ||
| 78 | ext = ".org" | ||
| 79 | } | ||
| 80 | file := control.AboutBase + ext | ||
| 81 | if _, err := gitutil.ReadBlob(dir, repo.DefaultBranch, file, 1); err == nil { | ||
| 82 | return false, nil // already there | ||
| 83 | } | ||
| 84 | // A commit carries an identity. An owner without a verified address, | ||
| 85 | // and every org, gets the noreply form rather than no commit. | ||
| 86 | email := row.OwnerName + "@users.noreply." + cfg.SiteHost() | ||
| 87 | if row.OwnerKind == "user" { | ||
| 88 | if addr, _ := st.PrimaryVerifiedEmail(row.OwnerID); addr != "" { | ||
| 89 | email = addr | ||
| 90 | } | ||
| 91 | } | ||
| 92 | if _, err := gitutil.CommitFileChange(dir, repo.DefaultBranch, file, | ||
| 93 | []byte(row.About), row.OwnerName, email, | ||
| 94 | "move profile about out of the database"); err != nil { | ||
| 95 | return false, err | ||
| 96 | } | ||
| 97 | return true, nil | ||
| 98 | } | ||
cmd/gitbayd/main.go +1
| @@ -399,6 +399,7 @@ func adminCmd() *cobra.Command { | |||
| 399 | backupCmd(), | 399 | backupCmd(), |
| 400 | gcCmd(), | 400 | gcCmd(), |
| 401 | adminMigrateCommitRefsCmd(), | 401 | adminMigrateCommitRefsCmd(), |
| 402 | adminMigrateProfileAboutCmd(), | ||
| 402 | adminBackfillActivityCmd(), | 403 | adminBackfillActivityCmd(), |
| 403 | ) | 404 | ) |
| 404 | return admin | 405 | return admin |
docs/plans/2026-09-19-profile-about-repo.md added +1306
| @@ -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. | ||
docs/specs/2026-09-19-profile-about-repo-design.md added +223
| @@ -0,0 +1,223 @@ | |||
| 1 | # Profile about text in a repository | ||
| 2 | |||
| 3 | Closes #236. | ||
| 4 | |||
| 5 | The `about` text on a user or org profile is a column on `users`/`orgs`, | ||
| 6 | set through `profile set --about` and rendered by `aboutHTML`. It is the | ||
| 7 | only long-form, version-worthy prose on the instance that is not a file | ||
| 8 | in a repository. This moves it into one. | ||
| 9 | |||
| 10 | Links, description and website stay where they are. The issue lists | ||
| 11 | links as a "consider"; moving them buys nothing the DB columns do not | ||
| 12 | already give, and a second file or a front-matter parser is cost without | ||
| 13 | a return. | ||
| 14 | |||
| 15 | ## Where it lives | ||
| 16 | |||
| 17 | `profile/README.md` or `profile/README.org` on the default branch of a | ||
| 18 | repository named `.gitbay` under the owner's namespace: | ||
| 19 | |||
| 20 | ``` | ||
| 21 | cmc/.gitbay | ||
| 22 | └── profile/ | ||
| 23 | └── README.org | ||
| 24 | ``` | ||
| 25 | |||
| 26 | Resolution order is the wiki's `wikiExts`: `.md`, `.org`, `.markdown`; | ||
| 27 | the first that exists wins. There are no store rows for the file — | ||
| 28 | access derives from the parent repository, exactly as | ||
| 29 | `internal/control/wiki.go` states for wiki pages. | ||
| 30 | |||
| 31 | A dot-repo rather than the GitHub-style `cmc/cmc` because `.gitbay` is | ||
| 32 | not single-purpose: it is the place later per-owner configuration | ||
| 33 | (issue templates, org defaults) goes, and a leading dot is the signal | ||
| 34 | that it is infrastructure rather than a project. | ||
| 35 | |||
| 36 | ### Reading | ||
| 37 | |||
| 38 | A helper in `internal/control/profile.go`: | ||
| 39 | |||
| 40 | ```go | ||
| 41 | // ownerAbout returns the about text and its format for an owner, read | ||
| 42 | // from profile/README.* on the default branch of <owner>/.gitbay. | ||
| 43 | // Everything missing — the repo, the branch, the file — is an empty | ||
| 44 | // about, as is a repo the caller cannot read. | ||
| 45 | func ownerAbout(c *Ctx, owner string) (text, format string) | ||
| 46 | ``` | ||
| 47 | |||
| 48 | It resolves `<owner>/.gitbay` through the same access check every other | ||
| 49 | read takes, so a repository the caller cannot read yields no about. The | ||
| 50 | blob read is capped at `maxCommitFileBytes` (1MB). | ||
| 51 | |||
| 52 | `ProfileOut.About` and `ProfileOut.AboutFormat` keep their JSON names | ||
| 53 | and meanings; only the source changes. `AboutFormat` is `org` for a | ||
| 54 | `.org` file and `md` otherwise. The API contract and the iOS client are | ||
| 55 | untouched. | ||
| 56 | |||
| 57 | One field is added: `about_path`, the repository-relative path the text | ||
| 58 | was read from, empty when there is no about. The web needs it to link to | ||
| 59 | the file rather than guess its extension, and every other client gets | ||
| 60 | the same pointer. | ||
| 61 | |||
| 62 | `aboutHTML` in `internal/httpd/web.go` becomes a direct | ||
| 63 | `renderReadme(name, raw)` call — there is a filename to dispatch on now, | ||
| 64 | so the stored-format indirection goes away. | ||
| 65 | |||
| 66 | ## Naming | ||
| 67 | |||
| 68 | `policy.namePat` requires a leading alphanumeric, so `.gitbay` is an | ||
| 69 | invalid repository name today: | ||
| 70 | |||
| 71 | ```go | ||
| 72 | var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`) | ||
| 73 | ``` | ||
| 74 | |||
| 75 | One optional leading dot, same 63-character ceiling. `ValidateName` | ||
| 76 | keeps refusing `.`, `..` and a `.git` suffix, and gains an exact-`.git` | ||
| 77 | refusal — the suffix rule only fires for names longer than four | ||
| 78 | characters. | ||
| 79 | |||
| 80 | Relaxing the pattern rather than whitelisting the one name `.gitbay` is | ||
| 81 | the smaller change, and it gives owners `.dotfiles` and the like for | ||
| 82 | free. | ||
| 83 | |||
| 84 | ## A first commit into an empty repository | ||
| 85 | |||
| 86 | `gitutil.CommitFileChange` resolves the target branch and fails when it | ||
| 87 | does not exist, so committing the first file into a freshly created | ||
| 88 | `.gitbay` is impossible today. Both the web's create button and the | ||
| 89 | backfill need it to work. | ||
| 90 | |||
| 91 | An unresolvable branch becomes a root commit **only when the repository | ||
| 92 | has no refs at all**. Anywhere else it stays the error it is now — a | ||
| 93 | typo'd branch name in a repository with history must not silently start | ||
| 94 | an orphan branch. | ||
| 95 | |||
| 96 | This also makes `repo commit-file` work on a repository created but | ||
| 97 | never pushed to, which is the same gap seen from the CLI. | ||
| 98 | |||
| 99 | ## Writing | ||
| 100 | |||
| 101 | There is no about-specific write command, for the reason the wiki has | ||
| 102 | none: the content is a file, and the file is written the way files are | ||
| 103 | written. | ||
| 104 | |||
| 105 | - `profile set` loses `--about`, `--about-format` and `--file`, and | ||
| 106 | loses `ReadsStdin`. | ||
| 107 | - `org profile` loses the same three flags. | ||
| 108 | - Authoring is a push, or | ||
| 109 | `repo commit-file cmc/.gitbay profile/README.org --ref main --file -`. | ||
| 110 | |||
| 111 | ### The web | ||
| 112 | |||
| 113 | The About textarea on the account page is replaced by a line naming the | ||
| 114 | file and linking to it, with a button that creates `<owner>/.gitbay` | ||
| 115 | and commits a starter `profile/README.md` when the repository does not | ||
| 116 | exist yet. Editing then happens in the repository file editor that | ||
| 117 | already exists. | ||
| 118 | |||
| 119 | The alternative — keeping the textarea and dispatching | ||
| 120 | `repo commit-file` from it — needs an auto-create path and has a stale | ||
| 121 | file problem: moving the format picker from md to org leaves a | ||
| 122 | `README.md` that keeps winning resolution, and `commit-file` cannot | ||
| 123 | delete it, so the handler needs a second dispatch to `file remove`. The | ||
| 124 | pointer is smaller and matches how a wiki page is edited. | ||
| 125 | |||
| 126 | The account form's `profile` case keeps `--description`, `--website` | ||
| 127 | and `--link`, and stops sending `--about-format` and stdin. The Preview | ||
| 128 | button on that form goes with the textarea; the repository file editor | ||
| 129 | has its own. | ||
| 130 | |||
| 131 | ## Access and visibility | ||
| 132 | |||
| 133 | The about renders to whoever can read `<owner>/.gitbay`. A private | ||
| 134 | `.gitbay` means the about is visible to the owner and admins only. That | ||
| 135 | is the parent-derived rule already in force for wiki pages, not a new | ||
| 136 | one. | ||
| 137 | |||
| 138 | The backfill creates the repository **public**, so no about that was | ||
| 139 | world-readable becomes hidden by the move. | ||
| 140 | |||
| 141 | Repositories whose name starts with `.` are filtered out of: | ||
| 142 | |||
| 143 | - the profile page's repository list (`ProfileOut.Repos`), and | ||
| 144 | - `explore`. | ||
| 145 | |||
| 146 | They stay in `repo list`, which is the owner's own inventory, and stay | ||
| 147 | reachable at their URL. Hiding the repository is what a dot-repo buys | ||
| 148 | over `cmc/cmc`; without the filter the move trades one visible | ||
| 149 | single-purpose repository for another. | ||
| 150 | |||
| 151 | ## Migration | ||
| 152 | |||
| 153 | A SQL migration cannot write git objects, so the move is two pieces | ||
| 154 | that ship together. `gitbayd` runs `MigrateUp` at startup, so a backfill | ||
| 155 | that reads the columns must not run after a migration that drops them — | ||
| 156 | hence the holding table. | ||
| 157 | |||
| 158 | **Migration 0058** copies every owner with a non-empty about into a | ||
| 159 | holding table, then drops the columns: | ||
| 160 | |||
| 161 | ```sql | ||
| 162 | CREATE TABLE profile_about_backfill ( | ||
| 163 | owner_kind TEXT NOT NULL, | ||
| 164 | owner_id INTEGER NOT NULL, | ||
| 165 | about TEXT NOT NULL, | ||
| 166 | about_format TEXT NOT NULL, | ||
| 167 | PRIMARY KEY (owner_kind, owner_id) | ||
| 168 | ); | ||
| 169 | INSERT INTO profile_about_backfill ... -- users, then orgs | ||
| 170 | ALTER TABLE users DROP COLUMN about; -- and about_format | ||
| 171 | ALTER TABLE orgs DROP COLUMN about; -- and about_format | ||
| 172 | ``` | ||
| 173 | |||
| 174 | The down migration re-adds the columns empty and drops the table. | ||
| 175 | |||
| 176 | **`gitbayd admin migrate-profile-about`**, in the shape of | ||
| 177 | `adminMigrateCommitRefsCmd` in `cmd/gitbayd/adminusers.go`, drains the | ||
| 178 | table. Per row: create `<owner>/.gitbay` public if it does not exist, | ||
| 179 | `gitutil.CommitFileChange` the README at the recorded format, delete | ||
| 180 | the row. Idempotent — an owner who already has the file is skipped and | ||
| 181 | their row deleted. | ||
| 182 | |||
| 183 | Commit identity is the owner's primary verified email when they have | ||
| 184 | one, otherwise `<name>@users.noreply.<host>`. Orgs have no email and | ||
| 185 | always take the fallback. | ||
| 186 | |||
| 187 | A later release drops the emptied holding table. | ||
| 188 | |||
| 189 | ## Testing | ||
| 190 | |||
| 191 | Unit: | ||
| 192 | |||
| 193 | - `policy`: `.gitbay` and `.dotfiles` accepted; `.`, `..`, `.git` and | ||
| 194 | `x.git` still refused; the 63-character ceiling holds with the dot. | ||
| 195 | - `ownerAbout`: `.md` wins over `.org`; a missing repo, a missing | ||
| 196 | branch and a missing file each give an empty about; a repo the caller | ||
| 197 | cannot read gives an empty about. | ||
| 198 | - `gitutil.CommitFileChange`: the first commit into an empty repository | ||
| 199 | succeeds and reads back; the second takes the parented path; an | ||
| 200 | unknown branch in a repository with history is still an error. | ||
| 201 | |||
| 202 | e2e, new `e2e/profileabout_test.go`: | ||
| 203 | |||
| 204 | - a committed `profile/README.md` shows on `profile show` and on the | ||
| 205 | web profile page; | ||
| 206 | - a private `.gitbay` hides the about from an outsider while the owner | ||
| 207 | still sees it; | ||
| 208 | - dot-repos do not appear in `explore` or in a profile's repository | ||
| 209 | list, and do appear in `repo list`; | ||
| 210 | - `profile set --about` is refused as an unknown flag. | ||
| 211 | |||
| 212 | e2e for the backfill in the shape of `e2e/commentmigrate_test.go`: | ||
| 213 | a row in the holding table becomes a repository with the file, and a | ||
| 214 | second run is a no-op. | ||
| 215 | |||
| 216 | `TestStdinCommandsReadStdin` already polices `profile set` dropping | ||
| 217 | `ReadsStdin`. | ||
| 218 | |||
| 219 | ## Documentation | ||
| 220 | |||
| 221 | - `.gitbay/wiki/Parity` — the profile row. | ||
| 222 | - `.gitbay/wiki/Users` — the profile section: where the about lives and | ||
| 223 | how to write it. | ||
e2e/aboutbackfill_test.go added +69
| @@ -0,0 +1,69 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "path/filepath" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/store" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // The about text parked by migration 0058 becomes a file in the owner's | ||
| 12 | // .gitbay repository. Running it twice writes nothing the second time. | ||
| 13 | func TestMigrateProfileAbout(t *testing.T) { | ||
| 14 | inst := startInstance(t) | ||
| 15 | aliceKey := inst.newKey(t, "alice") | ||
| 16 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 17 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 18 | |||
| 19 | // Seed the holding table the way the migration would have. | ||
| 20 | dbPath := filepath.Join(inst.root, "gitbay.db") | ||
| 21 | st, err := store.Open(dbPath) | ||
| 22 | if err != nil { | ||
| 23 | t.Fatal(err) | ||
| 24 | } | ||
| 25 | _, err = st.DB.Exec( | ||
| 26 | "INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) "+ | ||
| 27 | "VALUES ('user', (SELECT id FROM users WHERE username='alice'), ?, 'org')", | ||
| 28 | "* alice\n\ntext from the database\n") | ||
| 29 | st.Close() | ||
| 30 | if err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | |||
| 34 | inst.admin(t, "admin", "migrate-profile-about") | ||
| 35 | |||
| 36 | out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 37 | if code != 0 { | ||
| 38 | t.Fatalf("profile show: %d", code) | ||
| 39 | } | ||
| 40 | if !strings.Contains(out, "text from the database") { | ||
| 41 | t.Errorf("about not moved into the repository: %s", out) | ||
| 42 | } | ||
| 43 | if !strings.Contains(out, `"about_path":"profile/README.org"`) { | ||
| 44 | t.Errorf("about not written at the recorded format: %s", out) | ||
| 45 | } | ||
| 46 | |||
| 47 | // The repository it made is public, so nothing that was world-readable | ||
| 48 | // became hidden. | ||
| 49 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/.gitbay", "--json") | ||
| 50 | if !strings.Contains(out, `"visibility":"public"`) { | ||
| 51 | t.Errorf("backfilled repository is not public: %s", out) | ||
| 52 | } | ||
| 53 | |||
| 54 | // Idempotent: a second run is a no-op and the table stays empty. | ||
| 55 | inst.admin(t, "admin", "migrate-profile-about") | ||
| 56 | st, err = store.Open(dbPath) | ||
| 57 | if err != nil { | ||
| 58 | t.Fatal(err) | ||
| 59 | } | ||
| 60 | var n int | ||
| 61 | err = st.DB.QueryRow("SELECT count(*) FROM profile_about_backfill").Scan(&n) | ||
| 62 | st.Close() | ||
| 63 | if err != nil { | ||
| 64 | t.Fatal(err) | ||
| 65 | } | ||
| 66 | if n != 0 { | ||
| 67 | t.Errorf("holding table still has %d row(s)", n) | ||
| 68 | } | ||
| 69 | } | ||
e2e/previewweb_test.go +4 −8
| @@ -137,14 +137,10 @@ func TestMarkupPreviewWeb(t *testing.T) { | |||
| 137 | }) | 137 | }) |
| 138 | previewed("release edit", b, "<h2", "## release notes") | 138 | previewed("release edit", b, "<h2", "## release notes") |
| 139 | 139 | ||
| 140 | // The profile's about text. | 140 | // The profile form takes no markup: the about text is a file, and the |
| 141 | b = post(inst.base()+"/settings", url.Values{ | 141 | // file editor below is what previews it. |
| 142 | "field": {"profile"}, "about": {"# about me"}, "format": {"md"}, | 142 | if _, body := browserGet(t, alice, inst.base()+"/settings"); strings.Contains(body, `name="preview"`) { |
| 143 | "description": {"kept"}, "preview": {"1"}, | 143 | t.Fatalf("the profile form offers a preview:\n%s", body) |
| 144 | }) | ||
| 145 | previewed("profile about", b, "<h1", `value="kept"`) | ||
| 146 | if out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "--json"); strings.Contains(out, "about me") { | ||
| 147 | t.Fatalf("preview saved the profile: %s", out) | ||
| 148 | } | 144 | } |
| 149 | 145 | ||
| 150 | // The file editor previews a rendered path and offers nothing on one | 146 | // The file editor previews a rendered path and offers nothing on one |
e2e/profile_test.go +25 −28
| @@ -11,7 +11,9 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 11 | inst := startInstance(t) | 11 | inst := startInstance(t) |
| 12 | aliceKey := inst.newKey(t, "alice") | 12 | aliceKey := inst.newKey(t, "alice") |
| 13 | bobKey := inst.newKey(t, "bob") | 13 | bobKey := inst.newKey(t, "bob") |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | 14 | // A verified address, because the about text is a commit now. |
| 15 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 16 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 15 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | 17 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") |
| 16 | 18 | ||
| 17 | // Self-service user profile; website validated. | 19 | // Self-service user profile; website validated. |
| @@ -136,30 +138,22 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 136 | t.Fatalf("org profile show: %s", out) | 138 | t.Fatalf("org profile show: %s", out) |
| 137 | } | 139 | } |
| 138 | 140 | ||
| 139 | // About: long-form markdown, set inline or piped, rendered on the page. | 141 | // About: a file in <owner>/.gitbay, rendered on the page. The |
| 140 | if _, errOut, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n", | 142 | // extension picks the renderer; there is no stored format. |
| 141 | "profile", "set", "--file", "-"); code != 0 { | 143 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 { |
| 142 | t.Fatalf("about from stdin: %s", errOut) | 144 | t.Fatalf("creating alice/.gitbay: %s", errOut) |
| 143 | } | ||
| 144 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 145 | if !strings.Contains(out, "I maintain *small tools*.") { | ||
| 146 | t.Fatalf("about not stored: %s", out) | ||
| 147 | } | 145 | } |
| 148 | if !strings.Contains(out, "tinkerer") { | ||
| 149 | t.Fatalf("about clobbered the description: %s", out) | ||
| 150 | } | ||
| 151 | |||
| 152 | // Org-mode about: the stored format picks the renderer. | ||
| 153 | if _, errOut, code := inst.ssh(t, aliceKey, "* Tools\n\nI maintain /small tools/.\n", | 146 | if _, errOut, code := inst.ssh(t, aliceKey, "* Tools\n\nI maintain /small tools/.\n", |
| 154 | "profile", "set", "--file", "-", "--about-format", "org"); code != 0 { | 147 | "repo", "commit-file", "alice/.gitbay", "profile/README.org", |
| 148 | "--ref", "main", "--file", "-"); code != 0 { | ||
| 155 | t.Fatalf("org about: %s", errOut) | 149 | t.Fatalf("org about: %s", errOut) |
| 156 | } | 150 | } |
| 157 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | 151 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 158 | if !strings.Contains(out, `"about_format":"org"`) { | 152 | if !strings.Contains(out, `"about_format":"org"`) { |
| 159 | t.Fatalf("about format not stored: %s", out) | 153 | t.Fatalf("about format not read from the extension: %s", out) |
| 160 | } | 154 | } |
| 161 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about-format", "rst"); code != 2 { | 155 | if !strings.Contains(out, "tinkerer") { |
| 162 | t.Fatal("unknown about format accepted") | 156 | t.Fatalf("about clobbered the description: %s", out) |
| 163 | } | 157 | } |
| 164 | // Org emphasis parsed, not left as literal slashes the way the | 158 | // Org emphasis parsed, not left as literal slashes the way the |
| 165 | // markdown renderer would. | 159 | // markdown renderer would. |
| @@ -168,10 +162,15 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 168 | t.Fatalf("org about not rendered as org: %s", body) | 162 | t.Fatalf("org about not rendered as org: %s", body) |
| 169 | } | 163 | } |
| 170 | 164 | ||
| 171 | // Back to markdown for the rest of the checks. | 165 | // Markdown for the rest of the checks: .md wins the resolution order. |
| 172 | if _, _, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n", | 166 | if _, errOut, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n", |
| 173 | "profile", "set", "--file", "-", "--about-format", "md"); code != 0 { | 167 | "repo", "commit-file", "alice/.gitbay", "profile/README.md", |
| 174 | t.Fatal("markdown about") | 168 | "--ref", "main", "--file", "-"); code != 0 { |
| 169 | t.Fatalf("markdown about: %s", errOut) | ||
| 170 | } | ||
| 171 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 172 | if !strings.Contains(out, "I maintain *small tools*.") { | ||
| 173 | t.Fatalf("about not read from the repository: %s", out) | ||
| 175 | } | 174 | } |
| 176 | 175 | ||
| 177 | // Links: free-form, labelled or bare, capped, cleared by an empty one. | 176 | // Links: free-form, labelled or bare, capped, cleared by an empty one. |
| @@ -218,16 +217,14 @@ func TestOwnerProfiles(t *testing.T) { | |||
| 218 | t.Error("repositories render above the activity graph") | 217 | t.Error("repositories render above the activity graph") |
| 219 | } | 218 | } |
| 220 | 219 | ||
| 221 | // Clearing works the same way as the other fields. | 220 | // Clearing works the same way as the other fields. The about is not |
| 221 | // among them: it is a file, and it goes the way a file goes. | ||
| 222 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 { | 222 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 { |
| 223 | t.Fatal("clear links failed") | 223 | t.Fatal("clear links failed") |
| 224 | } | 224 | } |
| 225 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "''"); code != 0 { | ||
| 226 | t.Fatal("clear about failed") | ||
| 227 | } | ||
| 228 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | 225 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") |
| 229 | if strings.Contains(out, "fosstodon") || strings.Contains(out, "small tools") { | 226 | if strings.Contains(out, "fosstodon") { |
| 230 | t.Fatalf("about or links not cleared: %s", out) | 227 | t.Fatalf("links not cleared: %s", out) |
| 231 | } | 228 | } |
| 232 | status, body = inst.get(t, "/workshop") | 229 | status, body = inst.get(t, "/workshop") |
| 233 | if status != 200 || !strings.Contains(body, "where things get made") || | 230 | if status != 200 || !strings.Contains(body, "where things get made") || |
e2e/profileabout_test.go added +164
| @@ -0,0 +1,164 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // The about text is a file in <owner>/.gitbay, read on every surface with | ||
| 9 | // the reader's own access. | ||
| 10 | func TestProfileAboutFromRepo(t *testing.T) { | ||
| 11 | inst := startInstance(t) | ||
| 12 | aliceKey := inst.newKey(t, "alice") | ||
| 13 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 14 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 15 | bobKey := inst.newKey(t, "bob") | ||
| 16 | inst.admin(t, "admin", "user", "create", "bob", | ||
| 17 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | ||
| 18 | |||
| 19 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay"); code != 0 { | ||
| 20 | t.Fatal("creating alice/.gitbay failed") | ||
| 21 | } | ||
| 22 | if _, _, code := inst.ssh(t, aliceKey, "# alice\n\nhello from a file\n", | ||
| 23 | "repo", "commit-file", "alice/.gitbay", "profile/README.md", | ||
| 24 | "--ref", "main", "--file", "-"); code != 0 { | ||
| 25 | t.Fatal("committing the about failed") | ||
| 26 | } | ||
| 27 | |||
| 28 | out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") | ||
| 29 | if code != 0 { | ||
| 30 | t.Fatalf("profile show: %d", code) | ||
| 31 | } | ||
| 32 | if !strings.Contains(out, "hello from a file") { | ||
| 33 | t.Errorf("about not read from the repository: %s", out) | ||
| 34 | } | ||
| 35 | if !strings.Contains(out, `"about_format":"md"`) { | ||
| 36 | t.Errorf("about_format not md: %s", out) | ||
| 37 | } | ||
| 38 | if !strings.Contains(out, `"about_path":"profile/README.md"`) { | ||
| 39 | t.Errorf("about_path missing: %s", out) | ||
| 40 | } | ||
| 41 | |||
| 42 | _, body := inst.get(t, "/alice") | ||
| 43 | if !strings.Contains(body, "hello from a file") { | ||
| 44 | t.Error("web profile does not render the about") | ||
| 45 | } | ||
| 46 | |||
| 47 | // The extension drives the renderer: an .org about renders as org. | ||
| 48 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/.gitbay"); code != 0 { | ||
| 49 | t.Fatal("creating bob/.gitbay failed") | ||
| 50 | } | ||
| 51 | if _, errOut, code := inst.ssh(t, bobKey, "a /note/ in org\n", | ||
| 52 | "repo", "commit-file", "bob/.gitbay", "profile/README.org", | ||
| 53 | "--ref", "main", "--file", "-"); code != 0 { | ||
| 54 | t.Fatalf("committing bob's org about: %s", errOut) | ||
| 55 | } | ||
| 56 | if _, page := inst.get(t, "/bob"); !strings.Contains(page, "<em>note</em>") { | ||
| 57 | t.Errorf("about did not render as org:\n%s", page) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | // The extension picks the format, .md wins the resolution order, and a | ||
| 62 | // private .gitbay keeps the about to the people who can read it. | ||
| 63 | func TestProfileAboutFormatAndPrivacy(t *testing.T) { | ||
| 64 | inst := startInstance(t) | ||
| 65 | aliceKey := inst.newKey(t, "alice") | ||
| 66 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 67 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 68 | bobKey := inst.newKey(t, "bob") | ||
| 69 | inst.admin(t, "admin", "user", "create", "bob", | ||
| 70 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | ||
| 71 | |||
| 72 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay", "--private") | ||
| 73 | if _, _, code := inst.ssh(t, aliceKey, "* heading\n\norg text here\n", | ||
| 74 | "repo", "commit-file", "alice/.gitbay", "profile/README.org", | ||
| 75 | "--ref", "main", "--file", "-"); code != 0 { | ||
| 76 | t.Fatal("committing the org about failed") | ||
| 77 | } | ||
| 78 | |||
| 79 | out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 80 | if !strings.Contains(out, "org text here") || !strings.Contains(out, `"about_format":"org"`) { | ||
| 81 | t.Errorf("owner cannot read their own private about: %s", out) | ||
| 82 | } | ||
| 83 | |||
| 84 | out, _, code := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") | ||
| 85 | if code != 0 { | ||
| 86 | t.Fatalf("profile show for an outsider should succeed: %d", code) | ||
| 87 | } | ||
| 88 | if strings.Contains(out, "org text here") { | ||
| 89 | t.Errorf("private about leaked to an outsider: %s", out) | ||
| 90 | } | ||
| 91 | |||
| 92 | // A .md beside the .org wins: it is first in the resolution order. | ||
| 93 | if _, _, code := inst.ssh(t, aliceKey, "markdown wins\n", | ||
| 94 | "repo", "commit-file", "alice/.gitbay", "profile/README.md", | ||
| 95 | "--ref", "main", "--file", "-"); code != 0 { | ||
| 96 | t.Fatal("committing the md about failed") | ||
| 97 | } | ||
| 98 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 99 | if !strings.Contains(out, "markdown wins") { | ||
| 100 | t.Errorf(".md did not win resolution: %s", out) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | // A dot-repo is infrastructure: it stays out of explore and off the | ||
| 105 | // profile's repository list, and stays in the owner's own inventory. | ||
| 106 | func TestDotReposHiddenFromListings(t *testing.T) { | ||
| 107 | inst := startInstance(t) | ||
| 108 | aliceKey := inst.newKey(t, "alice") | ||
| 109 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 110 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 111 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/.gitbay") | ||
| 112 | inst.ssh(t, aliceKey, "", "repo", "create", "alice/app") | ||
| 113 | |||
| 114 | out, _, _ := inst.ssh(t, aliceKey, "", "explore", "--json") | ||
| 115 | if strings.Contains(out, ".gitbay") { | ||
| 116 | t.Errorf("dot-repo listed in explore: %s", out) | ||
| 117 | } | ||
| 118 | if !strings.Contains(out, "alice/app") { | ||
| 119 | t.Errorf("ordinary repo missing from explore: %s", out) | ||
| 120 | } | ||
| 121 | |||
| 122 | out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 123 | if strings.Contains(out, `"path":"alice/.gitbay"`) { | ||
| 124 | t.Errorf("dot-repo listed on the profile: %s", out) | ||
| 125 | } | ||
| 126 | if !strings.Contains(out, `"path":"alice/app"`) { | ||
| 127 | t.Errorf("ordinary repo missing from the profile: %s", out) | ||
| 128 | } | ||
| 129 | |||
| 130 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "list", "--json") | ||
| 131 | if !strings.Contains(out, "alice/.gitbay") { | ||
| 132 | t.Errorf("dot-repo missing from the owner's own inventory: %s", out) | ||
| 133 | } | ||
| 134 | |||
| 135 | // It is still reachable at its URL. | ||
| 136 | if _, page := inst.get(t, "/alice/.gitbay"); strings.Contains(page, "not found") { | ||
| 137 | t.Error("dot-repo page not reachable") | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | // The about is not settable through profile set any more: it is a file. | ||
| 142 | func TestProfileSetHasNoAbout(t *testing.T) { | ||
| 143 | inst := startInstance(t) | ||
| 144 | aliceKey := inst.newKey(t, "alice") | ||
| 145 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 146 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 147 | |||
| 148 | if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "'inline text'"); code == 0 { | ||
| 149 | t.Error("profile set --about still accepted") | ||
| 150 | } | ||
| 151 | if _, _, code := inst.ssh(t, aliceKey, "x", "profile", "set", "--file", "-"); code == 0 { | ||
| 152 | t.Error("profile set --file still accepted") | ||
| 153 | } | ||
| 154 | |||
| 155 | // The flags that stay still work. | ||
| 156 | if _, errOut, code := inst.ssh(t, aliceKey, "", | ||
| 157 | "profile", "set", "--description", "'a line'", "--link", "'site|https://example.org'"); code != 0 { | ||
| 158 | t.Fatalf("profile set --description --link: %s", errOut) | ||
| 159 | } | ||
| 160 | out, _, _ := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 161 | if !strings.Contains(out, "a line") || !strings.Contains(out, "https://example.org") { | ||
| 162 | t.Errorf("description or link not saved: %s", out) | ||
| 163 | } | ||
| 164 | } | ||
e2e/profileweb_test.go +48 −22
| @@ -7,8 +7,9 @@ import ( | |||
| 7 | ) | 7 | ) |
| 8 | 8 | ||
| 9 | // TestProfileSettingsWeb covers profile set from the account settings page | 9 | // TestProfileSettingsWeb covers profile set from the account settings page |
| 10 | // (#161): description, website, links and about round-trip through the | 10 | // (#161): description, website and links round-trip through the form, and |
| 11 | // form, and emptying a field actually clears it rather than being skipped. | 11 | // emptying a field actually clears it rather than being skipped. The about |
| 12 | // text is not on this form — it is a file, covered below. | ||
| 12 | func TestProfileSettingsWeb(t *testing.T) { | 13 | func TestProfileSettingsWeb(t *testing.T) { |
| 13 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | 14 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") |
| 14 | aliceKey := inst.newKey(t, "alice") | 15 | aliceKey := inst.newKey(t, "alice") |
| @@ -21,7 +22,7 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 21 | _, body := browserGet(t, alice, settingsURL) | 22 | _, body := browserGet(t, alice, settingsURL) |
| 22 | for _, want := range []string{ | 23 | for _, want := range []string{ |
| 23 | `<label for="p-description">`, `<label for="p-website">`, | 24 | `<label for="p-description">`, `<label for="p-website">`, |
| 24 | `<label for="p-links">`, `<label for="p-about">`, `<label for="format">`, | 25 | `<label for="p-links">`, |
| 25 | } { | 26 | } { |
| 26 | if !strings.Contains(body, want) { | 27 | if !strings.Contains(body, want) { |
| 27 | t.Fatalf("profile form missing %q:\n%s", want, body) | 28 | t.Fatalf("profile form missing %q:\n%s", want, body) |
| @@ -34,8 +35,6 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 34 | "description": {"builds small tools"}, | 35 | "description": {"builds small tools"}, |
| 35 | "website": {"https://alice.example"}, | 36 | "website": {"https://alice.example"}, |
| 36 | "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"}, | 37 | "links": {"Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now"}, |
| 37 | "about": {"hello there"}, | ||
| 38 | "format": {"md"}, | ||
| 39 | }) | 38 | }) |
| 40 | if status != 200 && status != 303 { | 39 | if status != 200 && status != 303 { |
| 41 | t.Fatalf("profile post: %d", status) | 40 | t.Fatalf("profile post: %d", status) |
| @@ -44,7 +43,7 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 44 | for _, want := range []string{ | 43 | for _, want := range []string{ |
| 45 | `"description":"builds small tools"`, `"website":"https://alice.example"`, | 44 | `"description":"builds small tools"`, `"website":"https://alice.example"`, |
| 46 | `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`, | 45 | `"label":"Mastodon"`, `"url":"https://fosstodon.example/@alice"`, |
| 47 | `"url":"https://alice.example/now"`, `"about":"hello there"`, | 46 | `"url":"https://alice.example/now"`, |
| 48 | } { | 47 | } { |
| 49 | if !strings.Contains(out, want) { | 48 | if !strings.Contains(out, want) { |
| 50 | t.Fatalf("profile set missing %q: %s", want, out) | 49 | t.Fatalf("profile set missing %q: %s", want, out) |
| @@ -55,7 +54,7 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 55 | _, body = browserGet(t, alice, settingsURL) | 54 | _, body = browserGet(t, alice, settingsURL) |
| 56 | for _, want := range []string{ | 55 | for _, want := range []string{ |
| 57 | "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice", | 56 | "builds small tools", "https://alice.example", "Mastodon|https://fosstodon.example/@alice", |
| 58 | "https://alice.example/now", "hello there", | 57 | "https://alice.example/now", |
| 59 | } { | 58 | } { |
| 60 | if !strings.Contains(body, want) { | 59 | if !strings.Contains(body, want) { |
| 61 | t.Fatalf("settings page did not round-trip %q:\n%s", want, body) | 60 | t.Fatalf("settings page did not round-trip %q:\n%s", want, body) |
| @@ -66,22 +65,10 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 66 | // each step below carries the fields already in place and changes one. | 65 | // each step below carries the fields already in place and changes one. |
| 67 | links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now" | 66 | links := "Mastodon|https://fosstodon.example/@alice\nhttps://alice.example/now" |
| 68 | 67 | ||
| 69 | // The org choice is honoured on the profile page. | ||
| 70 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | ||
| 71 | "field": {"profile"}, "description": {"builds small tools"}, | ||
| 72 | "website": {"https://alice.example"}, "links": {links}, | ||
| 73 | "about": {"a /note/ in org"}, "format": {"org"}, | ||
| 74 | }); status != 200 && status != 303 { | ||
| 75 | t.Fatalf("profile post (org): %d", status) | ||
| 76 | } | ||
| 77 | if _, page := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(page, "<em>note</em>") { | ||
| 78 | t.Fatalf("about did not render as org:\n%s", page) | ||
| 79 | } | ||
| 80 | |||
| 81 | // Emptying the website clears it, not leaves it alone. | 68 | // Emptying the website clears it, not leaves it alone. |
| 82 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | 69 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ |
| 83 | "field": {"profile"}, "description": {"builds small tools"}, | 70 | "field": {"profile"}, "description": {"builds small tools"}, |
| 84 | "website": {""}, "links": {links}, "about": {"a /note/ in org"}, "format": {"org"}, | 71 | "website": {""}, "links": {links}, |
| 85 | }); status != 200 && status != 303 { | 72 | }); status != 200 && status != 303 { |
| 86 | t.Fatalf("profile post (clear website): %d", status) | 73 | t.Fatalf("profile post (clear website): %d", status) |
| 87 | } | 74 | } |
| @@ -98,8 +85,7 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 98 | 85 | ||
| 99 | // Emptying the links field clears the whole list. | 86 | // Emptying the links field clears the whole list. |
| 100 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | 87 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ |
| 101 | "field": {"profile"}, "description": {"builds small tools"}, | 88 | "field": {"profile"}, "description": {"builds small tools"}, "links": {""}, |
| 102 | "links": {""}, "about": {"a /note/ in org"}, "format": {"org"}, | ||
| 103 | }); status != 200 && status != 303 { | 89 | }); status != 200 && status != 303 { |
| 104 | t.Fatalf("profile post (clear links): %d", status) | 90 | t.Fatalf("profile post (clear links): %d", status) |
| 105 | } | 91 | } |
| @@ -108,3 +94,43 @@ func TestProfileSettingsWeb(t *testing.T) { | |||
| 108 | t.Fatalf("links not cleared: %s", out) | 94 | t.Fatalf("links not cleared: %s", out) |
| 109 | } | 95 | } |
| 110 | } | 96 | } |
| 97 | |||
| 98 | // The settings page does not edit the about text; it creates the | ||
| 99 | // repository that holds it and points at the file editor. | ||
| 100 | func TestProfileAboutRepoFromWeb(t *testing.T) { | ||
| 101 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 102 | aliceKey := inst.newKey(t, "alice") | ||
| 103 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 104 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 105 | alice := inst.login(t, aliceKey) | ||
| 106 | settingsURL := inst.base() + "/settings" | ||
| 107 | |||
| 108 | // With no repository yet, the page offers to create one. | ||
| 109 | _, body := browserGet(t, alice, settingsURL) | ||
| 110 | if !strings.Contains(body, "Create alice/.gitbay") { | ||
| 111 | t.Fatalf("settings page does not offer the profile repository:\n%s", body) | ||
| 112 | } | ||
| 113 | |||
| 114 | if status, _ := browserPost(t, alice, settingsURL, url.Values{ | ||
| 115 | "field": {"profile-repo"}, | ||
| 116 | }); status != 200 && status != 303 { | ||
| 117 | t.Fatalf("profile-repo post: %d", status) | ||
| 118 | } | ||
| 119 | |||
| 120 | // The repository exists with a starter file, and the page now links to | ||
| 121 | // the editor instead of offering to create it again. | ||
| 122 | out, _, code := inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json") | ||
| 123 | if code != 0 { | ||
| 124 | t.Fatalf("profile show: %d", code) | ||
| 125 | } | ||
| 126 | if !strings.Contains(out, `"about_path":"profile/README.md"`) { | ||
| 127 | t.Fatalf("starter about not committed: %s", out) | ||
| 128 | } | ||
| 129 | _, body = browserGet(t, alice, settingsURL) | ||
| 130 | if !strings.Contains(body, "/alice/.gitbay/edit/main/profile/README.md") { | ||
| 131 | t.Fatalf("settings page does not link to the about file:\n%s", body) | ||
| 132 | } | ||
| 133 | if strings.Contains(body, "Create alice/.gitbay") { | ||
| 134 | t.Error("settings page still offers to create an existing repository") | ||
| 135 | } | ||
| 136 | } | ||
internal/control/explore.go +6
| @@ -3,6 +3,7 @@ package control | |||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "io" | 5 | "io" |
| 6 | "strings" | ||
| 6 | 7 | ||
| 7 | "gitbay.org/gitbay/internal/gitutil" | 8 | "gitbay.org/gitbay/internal/gitutil" |
| 8 | "gitbay.org/gitbay/internal/policy" | 9 | "gitbay.org/gitbay/internal/policy" |
| @@ -53,6 +54,11 @@ func runExplore(c *Ctx, args []string) int { | |||
| 53 | } | 54 | } |
| 54 | var ds []out | 55 | var ds []out |
| 55 | for _, repo := range repos { | 56 | for _, repo := range repos { |
| 57 | // A dot-repo is infrastructure, not a project: .gitbay holds an | ||
| 58 | // owner's profile content and has nothing to explore. | ||
| 59 | if strings.HasPrefix(repo.Name, ".") { | ||
| 60 | continue | ||
| 61 | } | ||
| 56 | if p.key != "" && repo.Path() <= p.key { | 62 | if p.key != "" && repo.Path() <= p.key { |
| 57 | continue | 63 | continue |
| 58 | } | 64 | } |
internal/control/migrate.go +1 −2
| @@ -166,8 +166,7 @@ func runAccountImportBundle(c *Ctx, args []string) int { | |||
| 166 | return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion) | 166 | return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion) |
| 167 | } | 167 | } |
| 168 | 168 | ||
| 169 | if b.Profile.Description != "" || b.Profile.Website != "" || | 169 | if b.Profile.Description != "" || b.Profile.Website != "" || len(b.Profile.Links) > 0 { |
| 170 | b.Profile.About != "" || len(b.Profile.Links) > 0 { | ||
| 171 | c.Store.SetOwnerProfile("user", c.User.ID, b.Profile) | 170 | c.Store.SetOwnerProfile("user", c.User.ID, b.Profile) |
| 172 | } | 171 | } |
| 173 | for _, addr := range b.Emails { | 172 | for _, addr := range b.Emails { |
internal/control/profile.go +75 −52
| @@ -20,44 +20,82 @@ func init() { | |||
| 20 | Usage: "profile show [name]", ReadOnly: true, Run: runProfileShow}) | 20 | Usage: "profile show [name]", ReadOnly: true, Run: runProfileShow}) |
| 21 | register(Command{Path: []string{"profile", "set"}, | 21 | register(Command{Path: []string{"profile", "set"}, |
| 22 | Summary: "set your profile", | 22 | Summary: "set your profile", |
| 23 | Usage: "profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]... ('' clears)", ReadsStdin: true, Run: runProfileSet}) | 23 | Usage: "profile set [--description <d>] [--website <url>] [--link <label|url>]... ('' clears)", Run: runProfileSet}) |
| 24 | register(Command{Path: []string{"org", "profile"}, | 24 | register(Command{Path: []string{"org", "profile"}, |
| 25 | Summary: "show or set an org's profile", | 25 | Summary: "show or set an org's profile", |
| 26 | Usage: "org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...", ReadsStdin: true, Run: runOrgProfile}) | 26 | Usage: "org profile <org> [--description <d>] [--website <url>] [--link <label|url>]...", Run: runOrgProfile}) |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | // maxProfileLinks caps the free-form link list. A profile is a header, | 29 | // maxProfileLinks caps the free-form link list. A profile is a header, |
| 30 | // not a linktree. | 30 | // not a linktree. |
| 31 | const maxProfileLinks = 5 | 31 | const maxProfileLinks = 5 |
| 32 | 32 | ||
| 33 | // ProfileRepoName is the repository that holds an owner's profile | ||
| 34 | // content. A dot-repo because it is infrastructure rather than a | ||
| 35 | // project: later per-owner configuration goes beside the about text, | ||
| 36 | // and the leading dot keeps it out of the listings. | ||
| 37 | const ProfileRepoName = ".gitbay" | ||
| 38 | |||
| 39 | // AboutBase is the about file's path in that repository, without its | ||
| 40 | // extension. | ||
| 41 | const AboutBase = "profile/README" | ||
| 42 | |||
| 43 | // aboutExts are the formats the about is read from, in resolution order | ||
| 44 | // — the wiki's order, for the same reason. | ||
| 45 | var aboutExts = []string{".md", ".org", ".markdown"} | ||
| 46 | |||
| 47 | // ownerAbout reads an owner's about text from <owner>/.gitbay. Anything | ||
| 48 | // missing — the repository, the branch, the file — is an empty about, | ||
| 49 | // and so is a repository this caller cannot read: a profile must not | ||
| 50 | // confirm a private namespace. path is the file it came from, so a | ||
| 51 | // client can link to it instead of guessing the extension. | ||
| 52 | func ownerAbout(c *Ctx, owner string) (text, format, path string) { | ||
| 53 | repo, err := c.Store.RepoByPath(owner + "/" + ProfileRepoName) | ||
| 54 | if err != nil { | ||
| 55 | return "", "", "" | ||
| 56 | } | ||
| 57 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | ||
| 58 | if err != nil || !policy.CanRead(c.User, repo, grant) { | ||
| 59 | return "", "", "" | ||
| 60 | } | ||
| 61 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | ||
| 62 | for _, ext := range aboutExts { | ||
| 63 | raw, err := gitutil.ReadBlob(dir, repo.DefaultBranch, AboutBase+ext, maxCommitFileBytes) | ||
| 64 | if err != nil || len(raw) == 0 { | ||
| 65 | continue | ||
| 66 | } | ||
| 67 | f := "md" | ||
| 68 | if ext == ".org" { | ||
| 69 | f = "org" | ||
| 70 | } | ||
| 71 | return string(raw), f, AboutBase + ext | ||
| 72 | } | ||
| 73 | return "", "", "" | ||
| 74 | } | ||
| 75 | |||
| 33 | // profileEdit is the set of profile fields a command may change. A nil | 76 | // profileEdit is the set of profile fields a command may change. A nil |
| 34 | // field is left alone; an empty value clears it. | 77 | // field is left alone; an empty value clears it. |
| 35 | type profileEdit struct { | 78 | type profileEdit struct { |
| 36 | Description *string | 79 | Description *string |
| 37 | Website *string | 80 | Website *string |
| 38 | About *string | ||
| 39 | AboutFormat *string | ||
| 40 | Links *[]store.ProfileLink | 81 | Links *[]store.ProfileLink |
| 41 | } | 82 | } |
| 42 | 83 | ||
| 43 | func (e profileEdit) empty() bool { | 84 | func (e profileEdit) empty() bool { |
| 44 | return e.Description == nil && e.Website == nil && e.About == nil && | 85 | return e.Description == nil && e.Website == nil && e.Links == nil |
| 45 | e.AboutFormat == nil && e.Links == nil | ||
| 46 | } | 86 | } |
| 47 | 87 | ||
| 48 | // parseProfileFlags pulls the profile flags out of args. --about takes | 88 | // parseProfileFlags pulls the profile flags out of args. --link repeats, |
| 49 | // inline text or reads stdin via --file -; --link repeats, and a single | 89 | // and a single empty --link clears the list. The about text is not here: |
| 50 | // empty --link clears the list. | 90 | // it is a file in <owner>/.gitbay, written like any other file. |
| 51 | func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) { | 91 | func parseProfileFlags(args []string) (rest []string, e profileEdit, err error) { |
| 52 | about, file := "", "" | ||
| 53 | sawAbout := false | ||
| 54 | var links []store.ProfileLink | 92 | var links []store.ProfileLink |
| 55 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--website", "--about", "--about-format", "--file"}, Multi: []string{"--link"}, MaxPos: -1}) | 93 | f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--website"}, Multi: []string{"--link"}, MaxPos: -1}) |
| 56 | if err != nil { | 94 | if err != nil { |
| 57 | return nil, e, err | 95 | return nil, e, err |
| 58 | } | 96 | } |
| 59 | rest = f.Pos | 97 | rest = f.Pos |
| 60 | for _, name := range []string{"--description", "--website", "--about-format"} { | 98 | for _, name := range []string{"--description", "--website"} { |
| 61 | if !f.Has(name) { | 99 | if !f.Has(name) { |
| 62 | continue | 100 | continue |
| 63 | } | 101 | } |
| @@ -67,16 +105,8 @@ func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err | |||
| 67 | e.Description = &v | 105 | e.Description = &v |
| 68 | case "--website": | 106 | case "--website": |
| 69 | e.Website = &v | 107 | e.Website = &v |
| 70 | case "--about-format": | ||
| 71 | e.AboutFormat = &v | ||
| 72 | } | 108 | } |
| 73 | } | 109 | } |
| 74 | if f.Has("--about") { | ||
| 75 | about, sawAbout = f.Value("--about"), true | ||
| 76 | } | ||
| 77 | if f.Has("--file") { | ||
| 78 | file, sawAbout = f.Value("--file"), true | ||
| 79 | } | ||
| 80 | for _, v := range f.List("--link") { | 110 | for _, v := range f.List("--link") { |
| 81 | if v == "" { | 111 | if v == "" { |
| 82 | links = nil | 112 | links = nil |
| @@ -90,13 +120,6 @@ func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err | |||
| 90 | links = append(links, l) | 120 | links = append(links, l) |
| 91 | e.Links = &links | 121 | e.Links = &links |
| 92 | } | 122 | } |
| 93 | if sawAbout { | ||
| 94 | body, berr := bodyFrom(c, about, file) | ||
| 95 | if berr != nil { | ||
| 96 | return nil, e, berr | ||
| 97 | } | ||
| 98 | e.About = &body | ||
| 99 | } | ||
| 100 | if len(links) > maxProfileLinks { | 123 | if len(links) > maxProfileLinks { |
| 101 | return nil, e, fmt.Errorf("at most %d links", maxProfileLinks) | 124 | return nil, e, fmt.Errorf("at most %d links", maxProfileLinks) |
| 102 | } | 125 | } |
| @@ -146,16 +169,6 @@ func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) { | |||
| 146 | } | 169 | } |
| 147 | p.Website = s | 170 | p.Website = s |
| 148 | } | 171 | } |
| 149 | if e.About != nil { | ||
| 150 | p.About = strings.TrimSpace(*e.About) | ||
| 151 | } | ||
| 152 | if e.AboutFormat != nil { | ||
| 153 | f := strings.TrimSpace(*e.AboutFormat) | ||
| 154 | if f != "md" && f != "org" { | ||
| 155 | return p, errors.New("about format must be md or org") | ||
| 156 | } | ||
| 157 | p.AboutFormat = f | ||
| 158 | } | ||
| 159 | if e.Links != nil { | 172 | if e.Links != nil { |
| 160 | p.Links = *e.Links | 173 | p.Links = *e.Links |
| 161 | } | 174 | } |
| @@ -167,11 +180,14 @@ type ProfileOut struct { | |||
| 167 | Kind string `json:"kind"` | 180 | Kind string `json:"kind"` |
| 168 | Description string `json:"description,omitempty"` | 181 | Description string `json:"description,omitempty"` |
| 169 | Website string `json:"website,omitempty"` | 182 | Website string `json:"website,omitempty"` |
| 170 | // About is long-form markdown, rendered by the web between the | 183 | // About is the long-form text from <owner>/.gitbay, rendered by the |
| 171 | // header and the activity graph. | 184 | // web between the header and the activity graph. |
| 172 | About string `json:"about,omitempty"` | 185 | About string `json:"about,omitempty"` |
| 173 | AboutFormat string `json:"about_format,omitempty"` | 186 | AboutFormat string `json:"about_format,omitempty"` |
| 174 | Links []store.ProfileLink `json:"links,omitempty"` | 187 | // AboutPath is where the about was read from in <owner>/.gitbay, so a |
| 188 | // client can link to the file rather than guess its extension. | ||
| 189 | AboutPath string `json:"about_path,omitempty"` | ||
| 190 | Links []store.ProfileLink `json:"links,omitempty"` | ||
| 175 | // The rest is what a profile page shows: who they work with, what | 191 | // The rest is what a profile page shows: who they work with, what |
| 176 | // they own that you can see, and how active they have been. The web | 192 | // they own that you can see, and how active they have been. The web |
| 177 | // read these straight out of the store, which kept them off every | 193 | // read these straight out of the store, which kept them off every |
| @@ -271,8 +287,10 @@ func runProfileShow(c *Ctx, args []string) int { | |||
| 271 | if err != nil { | 287 | if err != nil { |
| 272 | return c.fail(protocol.ExitFailure, "%v", err) | 288 | return c.fail(protocol.ExitFailure, "%v", err) |
| 273 | } | 289 | } |
| 290 | about, aboutFormat, aboutPath := ownerAbout(c, name) | ||
| 274 | d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, | 291 | d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, |
| 275 | About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}} | 292 | About: about, AboutFormat: aboutFormat, AboutPath: aboutPath, |
| 293 | Links: p.Links, Repos: []ProfileRepo{}} | ||
| 276 | 294 | ||
| 277 | // Who they work with. Both lists are public on a profile — the web | 295 | // Who they work with. Both lists are public on a profile — the web |
| 278 | // has always shown them — and neither exposes anything a member | 296 | // has always shown them — and neither exposes anything a member |
| @@ -302,6 +320,11 @@ func runProfileShow(c *Ctx, args []string) int { | |||
| 302 | return c.fail(protocol.ExitFailure, "%v", err) | 320 | return c.fail(protocol.ExitFailure, "%v", err) |
| 303 | } | 321 | } |
| 304 | for _, repo := range all { | 322 | for _, repo := range all { |
| 323 | // A dot-repo is infrastructure, not a project: .gitbay holds this | ||
| 324 | // profile's about text and does not belong in its listing. | ||
| 325 | if strings.HasPrefix(repo.Name, ".") { | ||
| 326 | continue | ||
| 327 | } | ||
| 305 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) | 328 | grant, err := c.Store.AccessRole(repo.ID, c.User.ID) |
| 306 | if err != nil { | 329 | if err != nil { |
| 307 | return c.fail(protocol.ExitFailure, "%v", err) | 330 | return c.fail(protocol.ExitFailure, "%v", err) |
| @@ -355,7 +378,7 @@ func runProfileShow(c *Ctx, args []string) int { | |||
| 355 | } | 378 | } |
| 356 | 379 | ||
| 357 | func runProfileSet(c *Ctx, args []string) int { | 380 | func runProfileSet(c *Ctx, args []string) int { |
| 358 | rest, e, err := parseProfileFlags(c, args) | 381 | rest, e, err := parseProfileFlags(args) |
| 359 | if err != nil { | 382 | if err != nil { |
| 360 | return c.failInput(err) | 383 | return c.failInput(err) |
| 361 | } | 384 | } |
| @@ -363,7 +386,7 @@ func runProfileSet(c *Ctx, args []string) int { | |||
| 363 | return c.usage() | 386 | return c.usage() |
| 364 | } | 387 | } |
| 365 | if e.empty() { | 388 | if e.empty() { |
| 366 | return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link") | 389 | return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website and/or --link") |
| 367 | } | 390 | } |
| 368 | p, err := c.Store.OwnerProfile("user", c.User.ID) | 391 | p, err := c.Store.OwnerProfile("user", c.User.ID) |
| 369 | if err != nil { | 392 | if err != nil { |
| @@ -377,12 +400,12 @@ func runProfileSet(c *Ctx, args []string) int { | |||
| 377 | return c.fail(protocol.ExitFailure, "%v", err) | 400 | return c.fail(protocol.ExitFailure, "%v", err) |
| 378 | } | 401 | } |
| 379 | return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user", | 402 | return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user", |
| 380 | Description: p.Description, Website: p.Website, About: p.About, | 403 | Description: p.Description, Website: p.Website, Links: p.Links, |
| 381 | AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}}) | 404 | Repos: []ProfileRepo{}}) |
| 382 | } | 405 | } |
| 383 | 406 | ||
| 384 | func runOrgProfile(c *Ctx, args []string) int { | 407 | func runOrgProfile(c *Ctx, args []string) int { |
| 385 | rest, e, err := parseProfileFlags(c, args) | 408 | rest, e, err := parseProfileFlags(args) |
| 386 | if err != nil { | 409 | if err != nil { |
| 387 | return c.failInput(err) | 410 | return c.failInput(err) |
| 388 | } | 411 | } |
| @@ -409,6 +432,6 @@ func runOrgProfile(c *Ctx, args []string) int { | |||
| 409 | return c.fail(protocol.ExitFailure, "%v", err) | 432 | return c.fail(protocol.ExitFailure, "%v", err) |
| 410 | } | 433 | } |
| 411 | return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org", | 434 | return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org", |
| 412 | Description: p.Description, Website: p.Website, About: p.About, | 435 | Description: p.Description, Website: p.Website, Links: p.Links, |
| 413 | AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}}) | 436 | Repos: []ProfileRepo{}}) |
| 414 | } | 437 | } |
internal/gitutil/commitfile_test.go added +38
| @@ -0,0 +1,38 @@ | |||
| 1 | package gitutil | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestCommitFileChangeEmptyRepo(t *testing.T) { | ||
| 6 | dir := t.TempDir() | ||
| 7 | if err := InitBare(dir, "main", ""); err != nil { | ||
| 8 | t.Fatal(err) | ||
| 9 | } | ||
| 10 | sha, err := CommitFileChange(dir, "main", "profile/README.md", | ||
| 11 | []byte("# hello\n"), "alice", "alice@example.org", "add about") | ||
| 12 | if err != nil { | ||
| 13 | t.Fatalf("first commit into an empty repository: %v", err) | ||
| 14 | } | ||
| 15 | if sha == "" { | ||
| 16 | t.Fatal("no sha returned") | ||
| 17 | } | ||
| 18 | raw, err := ReadBlob(dir, "main", "profile/README.md", 1<<20) | ||
| 19 | if err != nil { | ||
| 20 | t.Fatalf("reading it back: %v", err) | ||
| 21 | } | ||
| 22 | if string(raw) != "# hello\n" { | ||
| 23 | t.Errorf("read back %q", raw) | ||
| 24 | } | ||
| 25 | |||
| 26 | // The second commit still takes the parented path. | ||
| 27 | if _, err := CommitFileChange(dir, "main", "profile/README.md", | ||
| 28 | []byte("# hello again\n"), "alice", "alice@example.org", "edit"); err != nil { | ||
| 29 | t.Fatalf("second commit: %v", err) | ||
| 30 | } | ||
| 31 | |||
| 32 | // An unknown branch in a repository that has history is a typo, not a | ||
| 33 | // new orphan branch. | ||
| 34 | if _, err := CommitFileChange(dir, "nope", "x.md", | ||
| 35 | []byte("x"), "alice", "alice@example.org", "x"); err == nil { | ||
| 36 | t.Error("committing to an unknown branch of a non-empty repository succeeded") | ||
| 37 | } | ||
| 38 | } | ||
internal/gitutil/merge.go +24 −3
| @@ -189,7 +189,13 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes | |||
| 189 | branchRef := "refs/heads/" + branch | 189 | branchRef := "refs/heads/" + branch |
| 190 | parent, err := ResolveRef(dir, branchRef) | 190 | parent, err := ResolveRef(dir, branchRef) |
| 191 | if err != nil { | 191 | if err != nil { |
| 192 | return "", fmt.Errorf("branch %s: %w", branch, err) | 192 | // An unborn branch is a root commit only in a repository with no |
| 193 | // refs at all. Anywhere else an unresolvable branch is a typo, and | ||
| 194 | // starting an orphan branch for it would be worse than refusing. | ||
| 195 | if !isEmptyRepo(dir) { | ||
| 196 | return "", fmt.Errorf("branch %s: %w", branch, err) | ||
| 197 | } | ||
| 198 | parent = "" | ||
| 193 | } | 199 | } |
| 194 | 200 | ||
| 195 | // Hash the new blob. | 201 | // Hash the new blob. |
| @@ -211,7 +217,11 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes | |||
| 211 | defer os.Remove(idx.Name()) | 217 | defer os.Remove(idx.Name()) |
| 212 | env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name()) | 218 | env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name()) |
| 213 | 219 | ||
| 214 | rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}") | 220 | tree0 := parent + "^{tree}" |
| 221 | if parent == "" { | ||
| 222 | tree0 = "--empty" | ||
| 223 | } | ||
| 224 | rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", tree0) | ||
| 215 | rt.Env = env | 225 | rt.Env = env |
| 216 | if out, err := rt.CombinedOutput(); err != nil { | 226 | if out, err := rt.CombinedOutput(); err != nil { |
| 217 | return "", fmt.Errorf("read-tree: %v\n%s", err, out) | 227 | return "", fmt.Errorf("read-tree: %v\n%s", err, out) |
| @@ -229,7 +239,11 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes | |||
| 229 | } | 239 | } |
| 230 | tree := strings.TrimSpace(string(out)) | 240 | tree := strings.TrimSpace(string(out)) |
| 231 | 241 | ||
| 232 | sha, err := CommitTree(dir, tree, []string{parent}, name, email, message) | 242 | var parents []string |
| 243 | if parent != "" { | ||
| 244 | parents = []string{parent} | ||
| 245 | } | ||
| 246 | sha, err := CommitTree(dir, tree, parents, name, email, message) | ||
| 233 | if err != nil { | 247 | if err != nil { |
| 234 | return "", err | 248 | return "", err |
| 235 | } | 249 | } |
| @@ -239,6 +253,13 @@ func CommitFileChange(dir, branch, path string, content []byte, name, email, mes | |||
| 239 | return sha, nil | 253 | return sha, nil |
| 240 | } | 254 | } |
| 241 | 255 | ||
| 256 | // isEmptyRepo reports whether dir has no refs at all — a repository | ||
| 257 | // created but never pushed to. | ||
| 258 | func isEmptyRepo(dir string) bool { | ||
| 259 | out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output() | ||
| 260 | return err == nil && strings.TrimSpace(string(out)) == "" | ||
| 261 | } | ||
| 262 | |||
| 242 | // CommitParents returns the parent SHAs of a commit. | 263 | // CommitParents returns the parent SHAs of a commit. |
| 243 | func CommitParents(dir, sha string) ([]string, error) { | 264 | func CommitParents(dir, sha string) ([]string, error) { |
| 244 | out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output() | 265 | out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output() |
internal/httpd/account.go +33 −15
| @@ -34,12 +34,11 @@ type accountPGP struct { | |||
| 34 | // accountForm renders the account's own settings: keys, addresses, and the | 34 | // accountForm renders the account's own settings: keys, addresses, and the |
| 35 | // commands for everything that stays on SSH. | 35 | // commands for everything that stays on SSH. |
| 36 | func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { | 36 | func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 37 | s.accountPage(w, r, u, nil) | 37 | s.accountPage(w, r, u) |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | // accountPage renders the settings page. d is non-nil when the profile | 40 | // accountPage renders the settings page. |
| 41 | // form asked to see its about text rather than save it (#235). | 41 | func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) { |
| 42 | func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User, d *draft) { | ||
| 43 | var keys []accountKey | 42 | var keys []accountKey |
| 44 | if list, err := s.st.ListSSHKeys(u.ID); err == nil { | 43 | if list, err := s.st.ListSSHKeys(u.ID); err == nil { |
| 45 | for _, k := range list { | 44 | for _, k := range list { |
| @@ -67,6 +66,14 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use | |||
| 67 | watchOn, _ := s.st.WatchEnabled(u.ID) | 66 | watchOn, _ := s.st.WatchEnabled(u.ID) |
| 68 | theme, _ := s.st.Theme(u.ID) | 67 | theme, _ := s.st.Theme(u.ID) |
| 69 | 68 | ||
| 69 | // The about text is a file. The page points at it rather than editing | ||
| 70 | // it: the repository's own editor already does that job. | ||
| 71 | aboutRepo := u.Username + "/" + control.ProfileRepoName | ||
| 72 | aboutEdit := "" | ||
| 73 | if profile.AboutPath != "" { | ||
| 74 | aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath | ||
| 75 | } | ||
| 76 | |||
| 70 | s.render(w, "account.html", struct { | 77 | s.render(w, "account.html", struct { |
| 71 | basePage | 78 | basePage |
| 72 | Tab string // marks the rail's Settings row as current | 79 | Tab string // marks the rail's Settings row as current |
| @@ -75,15 +82,17 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.Use | |||
| 75 | Emails []store.Email | 82 | Emails []store.Email |
| 76 | Profile control.ProfileOut | 83 | Profile control.ProfileOut |
| 77 | LinksText string | 84 | LinksText string |
| 85 | AboutRepo string // <user>/.gitbay, which holds the about text | ||
| 86 | AboutEdit string // the file editor's URL, empty when there is no file yet | ||
| 78 | Host string | 87 | Host string |
| 79 | Notice string | 88 | Notice string |
| 80 | Message string | 89 | Message string |
| 81 | MailOn bool | 90 | MailOn bool |
| 82 | WatchOn bool | 91 | WatchOn bool |
| 83 | ThemeSetting string // system, light or dark: the form's selected option | 92 | ThemeSetting string // system, light or dark: the form's selected option |
| 84 | Draft *draft | 93 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 85 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), | 94 | aboutRepo, aboutEdit, s.cfg.SiteHost(), |
| 86 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme, d}) | 95 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme}) |
| 87 | } | 96 | } |
| 88 | 97 | ||
| 89 | // accountExport hands the browser the same bundle `account export` | 98 | // accountExport hands the browser the same bundle `account export` |
| @@ -246,25 +255,34 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 246 | } | 255 | } |
| 247 | back("", "notification preferences saved") | 256 | back("", "notification preferences saved") |
| 248 | case "profile": | 257 | case "profile": |
| 249 | format := bodyFormat(r) | ||
| 250 | if wantsPreview(r) { | ||
| 251 | s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML)) | ||
| 252 | return | ||
| 253 | } | ||
| 254 | argv := []string{"profile", "set", | 258 | argv := []string{"profile", "set", |
| 255 | "--description", r.FormValue("description"), | 259 | "--description", r.FormValue("description"), |
| 256 | "--website", r.FormValue("website"), | 260 | "--website", r.FormValue("website"), |
| 257 | "--about-format", format, | ||
| 258 | "--file", "-", | ||
| 259 | } | 261 | } |
| 260 | for _, link := range profileLinkArgs(r.FormValue("links")) { | 262 | for _, link := range profileLinkArgs(r.FormValue("links")) { |
| 261 | argv = append(argv, "--link", link) | 263 | argv = append(argv, "--link", link) |
| 262 | } | 264 | } |
| 263 | if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok { | 265 | if _, msg, ok := s.runControl(u, argv); !ok { |
| 264 | back(msg, "") | 266 | back(msg, "") |
| 265 | return | 267 | return |
| 266 | } | 268 | } |
| 267 | back("", "profile updated") | 269 | back("", "profile updated") |
| 270 | case "profile-repo": | ||
| 271 | // The about text is a file. Create the repository that holds it and | ||
| 272 | // commit a starter README, so the file editor has a branch to open. | ||
| 273 | path := u.Username + "/" + control.ProfileRepoName | ||
| 274 | if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok { | ||
| 275 | back(msg, "") | ||
| 276 | return | ||
| 277 | } | ||
| 278 | starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n" | ||
| 279 | if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path, | ||
| 280 | control.AboutBase + ".md", "--ref", "main", | ||
| 281 | "--message", "add profile about", "--file", "-"}, starter); !ok { | ||
| 282 | back(msg, "") | ||
| 283 | return | ||
| 284 | } | ||
| 285 | back("", "profile repository created") | ||
| 268 | default: | 286 | default: |
| 269 | back("unknown form", "") | 287 | back("unknown form", "") |
| 270 | } | 288 | } |
internal/httpd/web.go +8 −10
| @@ -442,8 +442,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |||
| 442 | weeks, activityTotal := activityGrid(counts) | 442 | weeks, activityTotal := activityGrid(counts) |
| 443 | 443 | ||
| 444 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) | 444 | teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) |
| 445 | profile := store.Profile{Description: d.Description, Website: d.Website, | 445 | profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} |
| 446 | About: d.About, AboutFormat: d.AboutFormat, Links: d.Links} | ||
| 447 | s.render(w, "owner.html", struct { | 446 | s.render(w, "owner.html", struct { |
| 448 | basePage | 447 | basePage |
| 449 | Owner string | 448 | Owner string |
| @@ -461,7 +460,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | |||
| 461 | Snippets int | 460 | Snippets int |
| 462 | Notice string | 461 | Notice string |
| 463 | Feed string | 462 | Feed string |
| 464 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), | 463 | }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(d.About, d.AboutFormat), |
| 465 | d.Repos, d.Members, d.Orgs, | 464 | d.Repos, d.Members, d.Orgs, |
| 466 | weeks, activityTotal, teams, canAdmin, | 465 | weeks, activityTotal, teams, canAdmin, |
| 467 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), | 466 | d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), |
| @@ -1124,18 +1123,17 @@ func mdHTML(raw string) template.HTML { | |||
| 1124 | return focusableBlocks(template.HTML(buf.String())) | 1123 | return focusableBlocks(template.HTML(buf.String())) |
| 1125 | } | 1124 | } |
| 1126 | 1125 | ||
| 1127 | // aboutHTML renders a profile's about text. It has no filename to | 1126 | // aboutHTML renders a profile's about text. The format comes from the |
| 1128 | // dispatch on, so the stored format picks the extension; anything other | 1127 | // file it was read from: org is org, anything else markdown. |
| 1129 | // than org is markdown. | 1128 | func aboutHTML(text, format string) template.HTML { |
| 1130 | func aboutHTML(p store.Profile) template.HTML { | 1129 | if strings.TrimSpace(text) == "" { |
| 1131 | if strings.TrimSpace(p.About) == "" { | ||
| 1132 | return "" | 1130 | return "" |
| 1133 | } | 1131 | } |
| 1134 | name := "about.md" | 1132 | name := "about.md" |
| 1135 | if p.AboutFormat == "org" { | 1133 | if format == "org" { |
| 1136 | name = "about.org" | 1134 | name = "about.org" |
| 1137 | } | 1135 | } |
| 1138 | return renderReadme(name, []byte(p.About)) | 1136 | return renderReadme(name, []byte(text)) |
| 1139 | } | 1137 | } |
| 1140 | 1138 | ||
| 1141 | // webResolver answers autolink lookups for one viewer. Cross-repo | 1139 | // webResolver answers autolink lookups for one viewer. Cross-repo |
internal/policy/names.go +14 −4
| @@ -34,15 +34,23 @@ var reservedNames = map[string]bool{ | |||
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, | 36 | // namePat matches valid user, org, and repo names: lowercase alphanumerics, |
| 37 | // dot, dash, underscore; must start with an alphanumeric. Dots are further | 37 | // dot, dash, underscore; must start with an alphanumeric, or with a single |
| 38 | // restricted by ValidateName to avoid "." / ".." and ".git" suffixes. | 38 | // dot before one. A leading dot marks a repository as infrastructure rather |
| 39 | var namePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | 39 | // than a project — .gitbay holds an owner's profile content — and is refused |
| 40 | // for owners by ValidateOwnerName. Dots are further restricted by | ||
| 41 | // ValidateName to avoid "." / ".." and ".git" suffixes. | ||
| 42 | var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`) | ||
| 40 | 43 | ||
| 41 | // ValidateOwnerName checks a username or org name. | 44 | // ValidateOwnerName checks a username or org name. |
| 42 | func ValidateOwnerName(name string) error { | 45 | func ValidateOwnerName(name string) error { |
| 43 | if err := ValidateName(name); err != nil { | 46 | if err := ValidateName(name); err != nil { |
| 44 | return err | 47 | return err |
| 45 | } | 48 | } |
| 49 | // The leading dot is a repository affordance. An owner is a top-level | ||
| 50 | // route, and /.gitbay is not one. | ||
| 51 | if strings.HasPrefix(name, ".") { | ||
| 52 | return fmt.Errorf("invalid name %q: must start with a letter or digit", name) | ||
| 53 | } | ||
| 46 | if reservedNames[name] { | 54 | if reservedNames[name] { |
| 47 | return fmt.Errorf("name %q is reserved", name) | 55 | return fmt.Errorf("name %q is reserved", name) |
| 48 | } | 56 | } |
| @@ -58,7 +66,9 @@ func ValidateName(name string) error { | |||
| 58 | if name == "." || name == ".." { | 66 | if name == "." || name == ".." { |
| 59 | return fmt.Errorf("invalid name %q", name) | 67 | return fmt.Errorf("invalid name %q", name) |
| 60 | } | 68 | } |
| 61 | if len(name) > 4 && name[len(name)-4:] == ".git" { | 69 | // HasSuffix covers "repo.git" and the bare ".git" the leading-dot rule |
| 70 | // would otherwise let through. | ||
| 71 | if strings.HasSuffix(name, ".git") { | ||
| 62 | return fmt.Errorf("invalid name %q: must not end in .git", name) | 72 | return fmt.Errorf("invalid name %q: must not end in .git", name) |
| 63 | } | 73 | } |
| 64 | // /{owner}/activity.atom is the owner's feed; a repository by that | 74 | // /{owner}/activity.atom is the owner's feed; a repository by that |
internal/policy/names_test.go +35 −1
| @@ -1,6 +1,9 @@ | |||
| 1 | package policy | 1 | package policy |
| 2 | 2 | ||
| 3 | import "testing" | 3 | import ( |
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 4 | 7 | ||
| 5 | func TestValidateOwnerName(t *testing.T) { | 8 | func TestValidateOwnerName(t *testing.T) { |
| 6 | valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"} | 9 | valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"} |
| @@ -43,3 +46,34 @@ func TestRepoNameAllowsReservedWords(t *testing.T) { | |||
| 43 | t.Error("ValidateName(\"activity.atom\") = nil, want error") | 46 | t.Error("ValidateName(\"activity.atom\") = nil, want error") |
| 44 | } | 47 | } |
| 45 | } | 48 | } |
| 49 | |||
| 50 | func TestRepoNameAllowsLeadingDot(t *testing.T) { | ||
| 51 | // .gitbay holds an owner's profile content; a leading dot marks a | ||
| 52 | // repository as infrastructure rather than a project. | ||
| 53 | for _, n := range []string{".gitbay", ".dotfiles", ".a"} { | ||
| 54 | if err := ValidateName(n); err != nil { | ||
| 55 | t.Errorf("ValidateName(%q) = %v, want nil", n, err) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | for _, n := range []string{".", "..", ".git", "repo.git", "..a", ".-a"} { | ||
| 59 | if err := ValidateName(n); err == nil { | ||
| 60 | t.Errorf("ValidateName(%q) = nil, want error", n) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | // The ceiling is 63 characters, the dot included. | ||
| 64 | if err := ValidateName("." + strings.Repeat("a", 62)); err != nil { | ||
| 65 | t.Errorf("63-character dotted name rejected: %v", err) | ||
| 66 | } | ||
| 67 | if err := ValidateName("." + strings.Repeat("a", 63)); err == nil { | ||
| 68 | t.Error("64-character dotted name accepted") | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | func TestOwnerNameRefusesLeadingDot(t *testing.T) { | ||
| 73 | // The dot is a repository affordance. An owner is a top-level route. | ||
| 74 | for _, n := range []string{".gitbay", ".hidden", ".a"} { | ||
| 75 | if err := ValidateOwnerName(n); err == nil { | ||
| 76 | t.Errorf("ValidateOwnerName(%q) = nil, want error", n) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
internal/store/aboutbackfill.go added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | // AboutRow is one owner's parked about text, waiting to become a file in | ||
| 4 | // <owner>/.gitbay. Migration 0058 fills the table; the backfill command | ||
| 5 | // drains it. | ||
| 6 | type AboutRow struct { | ||
| 7 | OwnerKind string | ||
| 8 | OwnerID int64 | ||
| 9 | OwnerName string | ||
| 10 | About string | ||
| 11 | Format string | ||
| 12 | } | ||
| 13 | |||
| 14 | // PendingAboutBackfill lists the owners whose about text has not been | ||
| 15 | // written to a repository yet, resolving each one's name. | ||
| 16 | func (s *Store) PendingAboutBackfill() ([]AboutRow, error) { | ||
| 17 | rows, err := s.DB.Query(` | ||
| 18 | SELECT b.owner_kind, b.owner_id, b.about, b.about_format, | ||
| 19 | COALESCE(u.username, o.name) | ||
| 20 | FROM profile_about_backfill b | ||
| 21 | LEFT JOIN users u ON b.owner_kind = 'user' AND u.id = b.owner_id | ||
| 22 | LEFT JOIN orgs o ON b.owner_kind = 'org' AND o.id = b.owner_id | ||
| 23 | ORDER BY b.owner_kind, b.owner_id`) | ||
| 24 | if err != nil { | ||
| 25 | return nil, err | ||
| 26 | } | ||
| 27 | defer rows.Close() | ||
| 28 | var out []AboutRow | ||
| 29 | for rows.Next() { | ||
| 30 | var r AboutRow | ||
| 31 | var name *string | ||
| 32 | if err := rows.Scan(&r.OwnerKind, &r.OwnerID, &r.About, &r.Format, &name); err != nil { | ||
| 33 | return nil, err | ||
| 34 | } | ||
| 35 | if name == nil { | ||
| 36 | continue // the owner is gone; the row goes with them | ||
| 37 | } | ||
| 38 | r.OwnerName = *name | ||
| 39 | out = append(out, r) | ||
| 40 | } | ||
| 41 | return out, rows.Err() | ||
| 42 | } | ||
| 43 | |||
| 44 | // ClearAboutBackfill drops one owner's row once its file exists. | ||
| 45 | func (s *Store) ClearAboutBackfill(kind string, id int64) error { | ||
| 46 | _, err := s.DB.Exec( | ||
| 47 | "DELETE FROM profile_about_backfill WHERE owner_kind = ? AND owner_id = ?", kind, id) | ||
| 48 | return err | ||
| 49 | } | ||
internal/store/migrations/0058_profile_about_out.down.sql added +20
| @@ -0,0 +1,20 @@ | |||
| 1 | ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; | ||
| 2 | ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | ||
| 3 | ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT ''; | ||
| 4 | ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md'; | ||
| 5 | |||
| 6 | UPDATE users SET | ||
| 7 | about = (SELECT about FROM profile_about_backfill | ||
| 8 | WHERE owner_kind = 'user' AND owner_id = users.id), | ||
| 9 | about_format = (SELECT about_format FROM profile_about_backfill | ||
| 10 | WHERE owner_kind = 'user' AND owner_id = users.id) | ||
| 11 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'user'); | ||
| 12 | |||
| 13 | UPDATE orgs SET | ||
| 14 | about = (SELECT about FROM profile_about_backfill | ||
| 15 | WHERE owner_kind = 'org' AND owner_id = orgs.id), | ||
| 16 | about_format = (SELECT about_format FROM profile_about_backfill | ||
| 17 | WHERE owner_kind = 'org' AND owner_id = orgs.id) | ||
| 18 | WHERE id IN (SELECT owner_id FROM profile_about_backfill WHERE owner_kind = 'org'); | ||
| 19 | |||
| 20 | DROP TABLE profile_about_backfill; | ||
internal/store/migrations/0058_profile_about_out.up.sql added +22
| @@ -0,0 +1,22 @@ | |||
| 1 | -- The about text moves into profile/README.* in <owner>/.gitbay. A SQL | ||
| 2 | -- migration cannot write git objects, so the text is parked here and | ||
| 3 | -- `gitbayd admin migrate-profile-about` drains the table into | ||
| 4 | -- repositories. A later release drops the emptied table. | ||
| 5 | CREATE TABLE profile_about_backfill ( | ||
| 6 | owner_kind TEXT NOT NULL, | ||
| 7 | owner_id INTEGER NOT NULL, | ||
| 8 | about TEXT NOT NULL, | ||
| 9 | about_format TEXT NOT NULL, | ||
| 10 | PRIMARY KEY (owner_kind, owner_id) | ||
| 11 | ); | ||
| 12 | |||
| 13 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) | ||
| 14 | SELECT 'user', id, about, about_format FROM users WHERE about <> ''; | ||
| 15 | |||
| 16 | INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) | ||
| 17 | SELECT 'org', id, about, about_format FROM orgs WHERE about <> ''; | ||
| 18 | |||
| 19 | ALTER TABLE users DROP COLUMN about; | ||
| 20 | ALTER TABLE users DROP COLUMN about_format; | ||
| 21 | ALTER TABLE orgs DROP COLUMN about; | ||
| 22 | ALTER TABLE orgs DROP COLUMN about_format; | ||
internal/store/orgs.go +9 −13
| @@ -224,13 +224,12 @@ func (s *Store) RenameOrg(orgID int64, newName string) error { | |||
| 224 | return tx.Commit() | 224 | return tx.Commit() |
| 225 | } | 225 | } |
| 226 | 226 | ||
| 227 | // Profile is the presentational half of a user or org. | 227 | // Profile is the presentational half of a user or org. The about text is |
| 228 | // not here: it is a file in <owner>/.gitbay, read through the control | ||
| 229 | // layer. | ||
| 228 | type Profile struct { | 230 | type Profile struct { |
| 229 | Description string `json:"description,omitempty"` | 231 | Description string `json:"description,omitempty"` |
| 230 | Website string `json:"website,omitempty"` | 232 | Website string `json:"website,omitempty"` |
| 231 | About string `json:"about,omitempty"` | ||
| 232 | // AboutFormat is "md" or "org"; About has no filename to dispatch on. | ||
| 233 | AboutFormat string `json:"about_format,omitempty"` | ||
| 234 | Links []ProfileLink `json:"links,omitempty"` | 233 | Links []ProfileLink `json:"links,omitempty"` |
| 235 | } | 234 | } |
| 236 | 235 | ||
| @@ -247,8 +246,8 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { | |||
| 247 | var p Profile | 246 | var p Profile |
| 248 | var linksJSON string | 247 | var linksJSON string |
| 249 | err := s.DB.QueryRow( | 248 | err := s.DB.QueryRow( |
| 250 | "SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id). | 249 | "SELECT description, website, links FROM "+table+" WHERE id = ?", id). |
| 251 | Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON) | 250 | Scan(&p.Description, &p.Website, &linksJSON) |
| 252 | if err != nil { | 251 | if err != nil { |
| 253 | return p, err | 252 | return p, err |
| 254 | } | 253 | } |
| @@ -263,9 +262,6 @@ func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { | |||
| 263 | // SetOwnerProfile updates the profile for kind "user" or "org". | 262 | // SetOwnerProfile updates the profile for kind "user" or "org". |
| 264 | func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { | 263 | func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { |
| 265 | table := map[string]string{"user": "users", "org": "orgs"}[kind] | 264 | table := map[string]string{"user": "users", "org": "orgs"}[kind] |
| 266 | if p.AboutFormat != "org" { | ||
| 267 | p.AboutFormat = "md" | ||
| 268 | } | ||
| 269 | links := "" | 265 | links := "" |
| 270 | if len(p.Links) > 0 { | 266 | if len(p.Links) > 0 { |
| 271 | raw, err := json.Marshal(p.Links) | 267 | raw, err := json.Marshal(p.Links) |
| @@ -275,7 +271,7 @@ func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { | |||
| 275 | links = string(raw) | 271 | links = string(raw) |
| 276 | } | 272 | } |
| 277 | _, err := s.DB.Exec( | 273 | _, err := s.DB.Exec( |
| 278 | "UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?", | 274 | "UPDATE "+table+" SET description = ?, website = ?, links = ? WHERE id = ?", |
| 279 | p.Description, p.Website, p.About, p.AboutFormat, links, id) | 275 | p.Description, p.Website, links, id) |
| 280 | return err | 276 | return err |
| 281 | } | 277 | } |
internal/web/templates/account.html +13 −8
| @@ -22,20 +22,25 @@ | |||
| 22 | <div class="colmain"> | 22 | <div class="colmain"> |
| 23 | <section id="profile"><h2>Profile</h2> | 23 | <section id="profile"><h2>Profile</h2> |
| 24 | <p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> | 24 | <p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> |
| 25 | {{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}} | ||
| 26 | <form method="post" action="/settings" class="setform stack"> | 25 | <form method="post" action="/settings" class="setform stack"> |
| 27 | <input type="hidden" name="field" value="profile"> | 26 | <input type="hidden" name="field" value="profile"> |
| 28 | <label for="p-description">Description</label> | 27 | <label for="p-description">Description</label> |
| 29 | <input type="text" id="p-description" name="description" value="{{.Draft.Or "about" "description" .Profile.Description}}" placeholder="one line, shown in listings"> | 28 | <input type="text" id="p-description" name="description" value="{{.Profile.Description}}" placeholder="one line, shown in listings"> |
| 30 | <label for="p-website">Website</label> | 29 | <label for="p-website">Website</label> |
| 31 | <input type="text" id="p-website" name="website" value="{{.Draft.Or "about" "website" .Profile.Website}}" placeholder="https://example.org"> | 30 | <input type="text" id="p-website" name="website" value="{{.Profile.Website}}" placeholder="https://example.org"> |
| 32 | <label for="p-links">Links</label> | 31 | <label for="p-links">Links</label> |
| 33 | <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.Draft.Or "about" "links" .LinksText}}</textarea> | 32 | <textarea id="p-links" name="links" rows="3" placeholder="one per line: label|https://... or a bare https://... (at most 5)">{{.LinksText}}</textarea> |
| 34 | <label for="p-about">About</label> | 33 | <span class="btngroup"><button type="submit" class="btn">Save profile</button></span> |
| 35 | <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Draft.Or "about" "about" .Profile.About}}</textarea> | ||
| 36 | {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}} | ||
| 37 | <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span> | ||
| 38 | </form> | 34 | </form> |
| 35 | <h3>About</h3> | ||
| 36 | <p class="meta">The longer text below your repositories is a file: | ||
| 37 | <code>profile/README.md</code> in <a href="/{{.AboutRepo}}">{{.AboutRepo}}</a>. | ||
| 38 | Write it with a push, or edit it here.</p> | ||
| 39 | {{if .AboutEdit}}<p><a class="btn" href="{{.AboutEdit}}">Edit {{.Profile.AboutPath}}</a></p> | ||
| 40 | {{else}}<form method="post" action="/settings" class="setform"> | ||
| 41 | <input type="hidden" name="field" value="profile-repo"> | ||
| 42 | <button type="submit" class="btn">Create {{.AboutRepo}}</button> | ||
| 43 | </form>{{end}} | ||
| 39 | </section> | 44 | </section> |
| 40 | 45 | ||
| 41 | <section id="keys"><h2>SSH keys</h2> | 46 | <section id="keys"><h2>SSH keys</h2> |
internal/web/templates/layout.html +3 −3
| @@ -135,9 +135,9 @@ | |||
| 135 | 135 | ||
| 136 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} | 136 | {{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} |
| 137 | 137 | ||
| 138 | {{/* formatpicker is the md/org choice on a body: issue and MR create, and | 138 | {{/* formatpicker is the md/org choice on a body: issue and MR create. |
| 139 | a profile's about text. The argument is the currently selected | 139 | The argument is the currently selected format; empty selects |
| 140 | format; empty selects Markdown, today's default. */}} | 140 | Markdown, today's default. */}} |
| 141 | {{define "formatpicker"}}<p class="branchpick"> | 141 | {{define "formatpicker"}}<p class="branchpick"> |
| 142 | <label for="format">Body markup</label> | 142 | <label for="format">Body markup</label> |
| 143 | <select id="format" name="format"> | 143 | <select id="format" name="format"> |