profile: the about text lives in <owner>/.gitbay !438

merged merged by cmc on 2026-09-20 01:20 UTC · krz/gitbay:profile-about-236 into main

28 files changed, +2364 −190

Layout: unified · split

.gitbay/wiki/Parity.org +22 −9
@@ -148,7 +148,7 @@ always markdown.
148 148
149Every web form that takes markup has a Preview button beside its own 149Every web form that takes markup has a Preview button beside its own
150submit: issue and merge request create, their edit and comment boxes, 150submit: issue and merge request create, their edit and comment boxes,
151release create and edit, the profile about text, and the file editor on 151release create and edit, and the file editor on
152a path the forge renders. It posts to the form's own action, which 152a path the forge renders. It posts to the form's own action, which
153renders the draft and hands the page back without writing, so what you 153renders the draft and hands the page back without writing, so what you
154see is the rendering the thread will show, autolinks included. It is a 154see 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
300syntax error. =repo grep= remains the per-repository file-contents 301syntax error. =repo grep= remains the per-repository file-contents
301search. 302search.
302 303
303About text renders as markdown or org-mode per the =about_format= it 304The about text is =profile/README.{md,org,markdown}= on the default
304was stored with. The iOS client decodes and renders both, 305branch of =<owner>/.gitbay=, resolved in that order, so the extension
306picks the renderer rather than a stored format. =profile show= reports
307it as =about=, =about_format= and =about_path=. It reads with the
308repository's own access, so a private =.gitbay= is a profile with no
309about text to anyone but its owner and the admins. A repository whose
310name starts with a dot stays out of =explore= and off the profile's
311repository list. The iOS client decodes and renders both formats,
305through the same OrgSwift path a README takes. 312through 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
360argument above — a JSON bundle has nowhere useful to land on a phone, 367argument above — a JSON bundle has nowhere useful to land on a phone,
361and the web route stays the way to get one. 368and 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
364API runs it like any other write. The account settings page has the form, and so does the iOS client: 371it 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 372does the iOS client: =--link= replaces the whole set rather than
366every link it keeps on every save, and =--link ''= is how they are 373appending, so a client sends every link it keeps on every save, and
367cleared. 374=--link ''= is how they are cleared.
375
376The about text is not among those flags. It is a file, written by a push
377or =repo commit-file= like any other file, which is why writing it is
378yes on every surface: anything that can commit a file can write it. The
379settings page points at the file and offers to create =<owner>/.gitbay=
380when 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
142gitbay profile set --description "builds small tools" --website https://alice.example 142gitbay profile set --description "builds small tools" --website https://alice.example
143#+end_src 143#+end_src
144 144
145About text is markdown by default, or org-mode. It takes inline text or 145The about text is not a field. It is =profile/README.md= or
146stdin, so it can live in a file you keep: 146=profile/README.org= on the default branch of =<owner>/.gitbay=, a
147repository 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
149gitbay profile set --about "I maintain a few small tools." 151gitbay repo create alice/.gitbay
150gitbay profile set --file - --about-format org < about.org 152gitbay repo commit-file alice/.gitbay profile/README.org \
153 --ref main --file - < about.org
151#+end_src 154#+end_src
152 155
156The extension picks the renderer; =.md=, =.org= and =.markdown= are
157resolved 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
161Access follows the repository: a private =.gitbay= keeps the about text
162to you and the admins. A repository whose name starts with a dot is
163infrastructure rather than a project, so it stays out of =explore= and
164off the profile's repository list — =repo list= still shows it, and it
165is reachable at its own URL.
166
153Up to five links, each =label|url= or a bare url, http(s) only. Passing 167Up 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
164work the same way and need org admin: 178work the same way and need org admin:
165 179
166#+begin_src sh 180#+begin_src sh
167gitbay org profile krz --description "software and experiments" --about-format org --file - < krz.org 181gitbay org profile krz --description "software and experiments"
168gitbay org profile krz # no flags shows it 182gitbay org profile krz # no flags shows it
169#+end_src 183#+end_src
170 184
171The web renders profiles but has no form for editing one, so the CLI is 185An org's about text works the same way, in =<org>/.gitbay=.
172the only interface today. The JSON API runs =profile set= like any 186
173other write command. 187The settings page edits description, website and links, and points at the
188about file — with a button that creates =<owner>/.gitbay= and its first
189README when you have none, so the file editor has a branch to open. The
190JSON 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
47The profile about text is a file (#236).
48
49*Upgrade note.* Run =gitbayd admin migrate-profile-about= after
50upgrading. Migration 0058 parks each owner's about text in a holding
51table and drops the columns; that command writes it into a public
52=<owner>/.gitbay=. Until it runs, profiles that had an about show none.
53Nothing 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
49Every command runs on every surface, markup previews before it is 74Every 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 @@
1package main
2
3import (
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.
17func 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.
57func 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`
6columns and into `profile/README.{md,org}` on the default branch of a
7repository named `.gitbay` under the owner's namespace.
8
9**Architecture:** The about becomes a file, read the way wiki pages are
10read — no store rows, access derived from the parent repository. The
11control command's JSON keeps its `about`/`about_format` fields, so the
12API 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
14SQL migration parks the existing text in a holding table and drops the
15columns; 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
43leading alphanumeric. Relax it, and close the hole that lets a
44repository 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
57Append to `internal/policy/names_test.go`:
58
59```go
60func 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
81Add `"strings"` to that file's imports if it is not already there.
82
83- [ ] **Step 2: Run it and watch it fail**
84
85Run: `go test ./internal/policy/ -run TestValidateNameLeadingDot -v`
86Expected: FAIL — `ValidateName(".gitbay")` returns an invalid-name error.
87
88- [ ] **Step 3: Relax the pattern**
89
90In `internal/policy/names.go`, replace the `namePat` declaration and its
91comment:
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".
99var 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
104In `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
112with:
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
127Run: `go test ./internal/policy/`
128Expected: PASS, including the pre-existing `TestValidateName`.
129
130- [ ] **Step 6: Commit**
131
132```bash
133git add internal/policy/names.go internal/policy/names_test.go
134git commit -m "policy: a repository name may start with a dot
135
136Ref #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,
144so committing the first file into a freshly created `.gitbay` is
145impossible. Task 4's web button and Task 6's backfill both need it.
146
147Allow a root commit **only when the repository has no refs at all**, so
148that a typo'd branch name in a repository with history still fails the
149way 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
164Create or append to `internal/gitutil/merge_test.go`:
165
166```go
167func 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
201Check `InitBare`'s signature in `internal/gitutil` before running; if
202its third parameter is not an optional hooks directory, pass what the
203existing callers in `internal/control/repo.go:217` pass.
204
205- [ ] **Step 2: Run it and watch it fail**
206
207Run: `go test ./internal/gitutil/ -run TestCommitFileChangeEmptyRepo -v`
208Expected: FAIL — `branch main: unknown ref "refs/heads/main"`.
209
210- [ ] **Step 3: Add the unborn-branch path**
211
212In `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
223with:
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
239Replace 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
249with:
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
263Replace the `CommitTree` call:
264
265```go
266 sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
267```
268
269with:
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
280the tail of the function is unchanged.
281
282- [ ] **Step 4: Add the emptiness check**
283
284Add 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.
289func 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
297Run: `go test ./internal/gitutil/`
298Expected: PASS.
299
300- [ ] **Step 6: Commit**
301
302```bash
303git add internal/gitutil/merge.go internal/gitutil/merge_test.go
304git commit -m "gitutil: commit-file writes the first commit of an empty repository
305
306Ref #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
333Create `e2e/profileabout_test.go`:
334
335```go
336package e2e
337
338import (
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.
345func 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.
383func 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
420Run: `go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m`
421Expected: 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
426In `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.
433const ProfileRepoName = ".gitbay"
434
435// AboutBase is the about file's path in that repository, without its
436// extension.
437const 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.
441var 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.
448func 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
475In `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
483In `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
494In `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.
499func 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
511In 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
518and update the `AboutHTML` field's value in the `s.render` struct
519literal to `aboutHTML(d.About, d.AboutFormat)`. Find its current call
520with `grep -n 'aboutHTML' internal/httpd/web.go` and change that
521argument list.
522
523- [ ] **Step 6: Build, vet, and run the tests**
524
525Run: `go build ./... && go vet ./... && go test ./e2e/ -run 'TestProfileAbout' -v -timeout 10m`
526Expected: PASS. `go vet` matters here — `aboutHTML`'s signature changed
527and `go build` does not compile `_test.go` callers.
528
529- [ ] **Step 7: Commit**
530
531```bash
532git add internal/control/profile.go internal/httpd/web.go e2e/profileabout_test.go
533git commit -m "profile: read the about text from <owner>/.gitbay
534
535Ref #236"
536```
537
538---
539
540### Task 4: Stop writing the about through `profile set`
541
542There is no about-specific write command, for the reason the wiki has
543none: 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
564Append to `e2e/profileabout_test.go`:
565
566```go
567// The about is not settable through profile set any more: it is a file.
568func 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
591Run: `go test ./e2e/ -run TestProfileSetHasNoAbout -v -timeout 10m`
592Expected: FAIL — `profile set --about` exits 0.
593
594- [ ] **Step 3: Drop the flags from the command registrations**
595
596In `internal/control/profile.go`'s `init`, replace the two
597registrations:
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
609that 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
613In `profileEdit`, delete the `About` and `AboutFormat` fields. Update
614`empty()`:
615
616```go
617func (e profileEdit) empty() bool {
618 return e.Description == nil && e.Website == nil && e.Links == nil
619}
620```
621
622In `parseProfileFlags`, delete the `about`, `file` and `sawAbout`
623locals, the `--about` / `--about-format` / `--file` entries from
624`flagSpec.Values`, the `--about-format` case from the loop over the
625value 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
628callers — but if the compiler reports `c` unused, rename it to `_` in
629the parameter list and update both call sites.
630
631In `applyProfile`, delete the `e.About` and `e.AboutFormat` blocks.
632
633In `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
639In 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
644Run: `go build ./... && go vet ./...`
645Expected: 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
652In `internal/httpd/account.go`, in the `profile` case of the settings
653form 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
696and 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
706then add `aboutRepo, aboutEdit` to the struct literal's value list in
707the same position as the fields.
708
709- [ ] **Step 7: Replace the textarea with the pointer**
710
711In `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}}
721It 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
730Run: `go build ./... && go vet ./... && go test ./internal/httpd/ ./internal/control/ && go test ./e2e/ -run 'TestProfile' -v -timeout 10m`
731Expected: PASS. If `TestMainWidthClass` fails, a template was added —
732it was not, so investigate rather than paper over it.
733
734- [ ] **Step 9: Commit**
735
736```bash
737git add internal/control/profile.go internal/httpd/account.go internal/web/templates/account.html e2e/profileabout_test.go
738git commit -m "profile: the about text is written as a file, not a flag
739
740Ref #236"
741```
742
743---
744
745### Task 5: Hide dot-repos from explore and profile listings
746
747Hiding the repository is what a dot-repo buys over `cmc/cmc`; without
748this 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
761Append 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.
766func 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
798Check `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
803Run: `go test ./e2e/ -run TestDotReposHiddenFromListings -v -timeout 10m`
804Expected: FAIL — `.gitbay` appears in explore and on the profile.
805
806- [ ] **Step 3: Filter the two listings**
807
808In `internal/control/explore.go`, inside the `for _, repo := range repos`
809loop, 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
819Add `"strings"` to that file's imports if absent.
820
821In `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
832Run: `go build ./... && go test ./e2e/ -run 'TestProfile|TestDotRepos' -v -timeout 10m`
833Expected: PASS.
834
835- [ ] **Step 5: Commit**
836
837```bash
838git add internal/control/explore.go internal/control/profile.go e2e/profileabout_test.go
839git commit -m "explore, profile: dot-repos stay out of the listings
840
841Ref #236"
842```
843
844---
845
846### Task 6: Migration and backfill
847
848A SQL migration cannot write git objects, and `gitbayd` runs
849`MigrateUp` at startup — so a backfill that reads the columns must not
850run after the migration that drops them. The migration parks the text
851in 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.
883CREATE 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
891INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
892SELECT 'user', id, about, about_format FROM users WHERE about <> '';
893
894INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
895SELECT 'org', id, about, about_format FROM orgs WHERE about <> '';
896
897ALTER TABLE users DROP COLUMN about;
898ALTER TABLE users DROP COLUMN about_format;
899ALTER TABLE orgs DROP COLUMN about;
900ALTER TABLE orgs DROP COLUMN about_format;
901```
902
903`internal/store/migrations/0058_profile_about_out.down.sql`:
904
905```sql
906ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
907ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
908ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
909ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
910
911UPDATE 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
917UPDATE 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
923DROP TABLE profile_about_backfill;
924```
925
926- [ ] **Step 2: Run the migration round-trip test**
927
928Run: `go test ./internal/store/ -run TestMigrateUpDown -v`
929Expected: FAIL to compile — `OwnerProfile` still selects the dropped
930columns. Proceed to Step 3, then re-run.
931
932- [ ] **Step 3: Take the about out of the store's profile**
933
934In `internal/store/orgs.go`, delete the `About` and `AboutFormat` fields
935from `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.
941type Profile struct {
942 Description string `json:"description,omitempty"`
943 Website string `json:"website,omitempty"`
944 Links []ProfileLink `json:"links,omitempty"`
945}
946```
947
948In `OwnerProfile`, drop the two columns from the SELECT and the two
949scan 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
957In `SetOwnerProfile`, drop the `AboutFormat` defaulting block and the
958two 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
968Run: `go build ./... && go vet ./... && go test ./internal/store/`
969Expected: PASS. `internal/control/migrate.go` embeds `store.Profile` in
970its account bundle; the fields simply disappear from that JSON, and an
971older bundle carrying them still imports because `encoding/json` ignores
972unknown fields. No bundle version bump.
973
974- [ ] **Step 5: Write the failing backfill test**
975
976Create `e2e/aboutbackfill_test.go`:
977
978```go
979package e2e
980
981import (
982 "path/filepath"
983 "strings"
984 "testing"
985
986 "gitbay.org/gitbay/internal/store"
987)
988
989func 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
1040Run: `go test ./e2e/ -run TestMigrateProfileAbout -v -timeout 10m`
1041Expected: FAIL — no such subcommand.
1042
1043- [ ] **Step 7: Read and clear the holding table**
1044
1045Create `internal/store/aboutbackfill.go`:
1046
1047```go
1048package 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.
1053type 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.
1063func (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.
1092func (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
1101Create `cmd/gitbayd/adminabout.go`:
1102
1103```go
1104package main
1105
1106import (
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.
1121func 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.
1161func 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
1199Confirm `cfg.SiteHost()`, `control.HooksDir`, `st.PrimaryVerifiedEmail`
1200and `st.DeleteRepo` exist with those names:
1201`grep -rn 'func.*SiteHost\|func HooksDir\|func (s \*Store) PrimaryVerifiedEmail\|func (s \*Store) DeleteRepo' internal/ | head`.
1202Adjust the calls to what is actually there rather than adding shims.
1203
1204- [ ] **Step 9: Register the subcommand**
1205
1206In `cmd/gitbayd/main.go`, in the `admin.AddCommand(` list that already
1207contains `adminMigrateCommitRefsCmd(),` (around line 401), add:
1208
1209```go
1210 adminMigrateProfileAboutCmd(),
1211```
1212
1213- [ ] **Step 10: Run the tests**
1214
1215Run: `go build ./... && go vet ./... && go test ./internal/store/ && go test ./e2e/ -run 'TestMigrateProfileAbout|TestProfile|TestDotRepos' -v -timeout 15m`
1216Expected: PASS.
1217
1218- [ ] **Step 11: Commit**
1219
1220```bash
1221git 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
1226git commit -m "store: move the parked about text into each owner's .gitbay
1227
1228Ref #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
1246Run: `grep -n 'about\|profile' .gitbay/wiki/Parity.md .gitbay/wiki/Users.md | head -30`
1247
1248- [ ] **Step 2: Update Parity**
1249
1250The 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
1254column order and marker vocabulary — read the surrounding rows first and
1255match them.
1256
1257- [ ] **Step 3: Update Users**
1258
1259In the profile section, replace the `--about` documentation with where
1260the file lives, the resolution order (`.md`, `.org`, `.markdown`), that
1261a private `.gitbay` keeps the about private, and that `.gitbay` does not
1262appear in explore or on the profile's repository list. Match the page's
1263existing voice.
1264
1265- [ ] **Step 4: Update the changelog**
1266
1267Add an entry under the current unreleased heading in `CHANGELOG.org`,
1268matching 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
1282Run: `go build ./... && go vet ./... && go test ./internal/... `
1283Expected: PASS. The full e2e suite belongs to CI on bay1.
1284
1285- [ ] **Step 6: Commit**
1286
1287```bash
1288git add .gitbay/wiki/Parity.md .gitbay/wiki/Users.md CHANGELOG.org
1289git commit -m "docs: the profile about text lives in a repository
1290
1291Closes #236"
1292```
1293
1294---
1295
1296## Notes for whoever runs the deploy
1297
1298The migration and the backfill are one release but two steps. After
1299`make deploy` has restarted `gitbayd` (which runs `MigrateUp`), run:
1300
1301```bash
1302ssh -p 2222 root@gitbay.org gitbayd admin migrate-profile-about
1303```
1304
1305Until it runs, profiles that had an about show none. The holding table
1306keeps 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
3Closes #236.
4
5The `about` text on a user or org profile is a column on `users`/`orgs`,
6set through `profile set --about` and rendered by `aboutHTML`. It is the
7only long-form, version-worthy prose on the instance that is not a file
8in a repository. This moves it into one.
9
10Links, description and website stay where they are. The issue lists
11links as a "consider"; moving them buys nothing the DB columns do not
12already give, and a second file or a front-matter parser is cost without
13a return.
14
15## Where it lives
16
17`profile/README.md` or `profile/README.org` on the default branch of a
18repository named `.gitbay` under the owner's namespace:
19
20```
21cmc/.gitbay
22└── profile/
23 └── README.org
24```
25
26Resolution order is the wiki's `wikiExts`: `.md`, `.org`, `.markdown`;
27the first that exists wins. There are no store rows for the file —
28access derives from the parent repository, exactly as
29`internal/control/wiki.go` states for wiki pages.
30
31A dot-repo rather than the GitHub-style `cmc/cmc` because `.gitbay` is
32not single-purpose: it is the place later per-owner configuration
33(issue templates, org defaults) goes, and a leading dot is the signal
34that it is infrastructure rather than a project.
35
36### Reading
37
38A 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.
45func ownerAbout(c *Ctx, owner string) (text, format string)
46```
47
48It resolves `<owner>/.gitbay` through the same access check every other
49read takes, so a repository the caller cannot read yields no about. The
50blob read is capped at `maxCommitFileBytes` (1MB).
51
52`ProfileOut.About` and `ProfileOut.AboutFormat` keep their JSON names
53and meanings; only the source changes. `AboutFormat` is `org` for a
54`.org` file and `md` otherwise. The API contract and the iOS client are
55untouched.
56
57One field is added: `about_path`, the repository-relative path the text
58was read from, empty when there is no about. The web needs it to link to
59the file rather than guess its extension, and every other client gets
60the 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,
64so the stored-format indirection goes away.
65
66## Naming
67
68`policy.namePat` requires a leading alphanumeric, so `.gitbay` is an
69invalid repository name today:
70
71```go
72var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`)
73```
74
75One optional leading dot, same 63-character ceiling. `ValidateName`
76keeps refusing `.`, `..` and a `.git` suffix, and gains an exact-`.git`
77refusal — the suffix rule only fires for names longer than four
78characters.
79
80Relaxing the pattern rather than whitelisting the one name `.gitbay` is
81the smaller change, and it gives owners `.dotfiles` and the like for
82free.
83
84## A first commit into an empty repository
85
86`gitutil.CommitFileChange` resolves the target branch and fails when it
87does not exist, so committing the first file into a freshly created
88`.gitbay` is impossible today. Both the web's create button and the
89backfill need it to work.
90
91An unresolvable branch becomes a root commit **only when the repository
92has no refs at all**. Anywhere else it stays the error it is now — a
93typo'd branch name in a repository with history must not silently start
94an orphan branch.
95
96This also makes `repo commit-file` work on a repository created but
97never pushed to, which is the same gap seen from the CLI.
98
99## Writing
100
101There is no about-specific write command, for the reason the wiki has
102none: the content is a file, and the file is written the way files are
103written.
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
113The About textarea on the account page is replaced by a line naming the
114file and linking to it, with a button that creates `<owner>/.gitbay`
115and commits a starter `profile/README.md` when the repository does not
116exist yet. Editing then happens in the repository file editor that
117already exists.
118
119The alternative — keeping the textarea and dispatching
120`repo commit-file` from it — needs an auto-create path and has a stale
121file problem: moving the format picker from md to org leaves a
122`README.md` that keeps winning resolution, and `commit-file` cannot
123delete it, so the handler needs a second dispatch to `file remove`. The
124pointer is smaller and matches how a wiki page is edited.
125
126The account form's `profile` case keeps `--description`, `--website`
127and `--link`, and stops sending `--about-format` and stdin. The Preview
128button on that form goes with the textarea; the repository file editor
129has its own.
130
131## Access and visibility
132
133The about renders to whoever can read `<owner>/.gitbay`. A private
134`.gitbay` means the about is visible to the owner and admins only. That
135is the parent-derived rule already in force for wiki pages, not a new
136one.
137
138The backfill creates the repository **public**, so no about that was
139world-readable becomes hidden by the move.
140
141Repositories whose name starts with `.` are filtered out of:
142
143- the profile page's repository list (`ProfileOut.Repos`), and
144- `explore`.
145
146They stay in `repo list`, which is the owner's own inventory, and stay
147reachable at their URL. Hiding the repository is what a dot-repo buys
148over `cmc/cmc`; without the filter the move trades one visible
149single-purpose repository for another.
150
151## Migration
152
153A SQL migration cannot write git objects, so the move is two pieces
154that ship together. `gitbayd` runs `MigrateUp` at startup, so a backfill
155that reads the columns must not run after a migration that drops them —
156hence the holding table.
157
158**Migration 0058** copies every owner with a non-empty about into a
159holding table, then drops the columns:
160
161```sql
162CREATE 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);
169INSERT INTO profile_about_backfill ... -- users, then orgs
170ALTER TABLE users DROP COLUMN about; -- and about_format
171ALTER TABLE orgs DROP COLUMN about; -- and about_format
172```
173
174The 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
178table. Per row: create `<owner>/.gitbay` public if it does not exist,
179`gitutil.CommitFileChange` the README at the recorded format, delete
180the row. Idempotent — an owner who already has the file is skipped and
181their row deleted.
182
183Commit identity is the owner's primary verified email when they have
184one, otherwise `<name>@users.noreply.<host>`. Orgs have no email and
185always take the fallback.
186
187A later release drops the emptied holding table.
188
189## Testing
190
191Unit:
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
202e2e, 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
212e2e for the backfill in the shape of `e2e/commentmigrate_test.go`:
213a row in the holding table becomes a repository with the file, and a
214second 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 @@
1package e2e
2
3import (
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.
13func 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 @@
1package e2e
2
3import (
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.
10func 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.
63func 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.
106func 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.
142func 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.
12func TestProfileSettingsWeb(t *testing.T) { 13func 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.
100func 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
3import ( 3import (
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.
31const maxProfileLinks = 5 31const 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.
37const ProfileRepoName = ".gitbay"
38
39// AboutBase is the about file's path in that repository, without its
40// extension.
41const 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.
45var 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.
52func 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.
35type profileEdit struct { 78type 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
43func (e profileEdit) empty() bool { 84func (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.
51func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) { 91func 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
357func runProfileSet(c *Ctx, args []string) int { 380func 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
384func runOrgProfile(c *Ctx, args []string) int { 407func 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 @@
1package gitutil
2
3import "testing"
4
5func 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.
258func 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.
243func CommitParents(dir, sha string) ([]string, error) { 264func 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.
36func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { 36func (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). 41func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
42func (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. 1128func aboutHTML(text, format string) template.HTML {
1130func 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
39var 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.
42var 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.
42func ValidateOwnerName(name string) error { 45func 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 @@
1package policy 1package policy
2 2
3import "testing" 3import (
4 "strings"
5 "testing"
6)
4 7
5func TestValidateOwnerName(t *testing.T) { 8func 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
50func 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
72func 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 @@
1package 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.
6type 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.
16func (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.
45func (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 @@
1ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
2ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
3ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
4ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
5
6UPDATE 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
13UPDATE 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
20DROP 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.
5CREATE 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
13INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
14SELECT 'user', id, about, about_format FROM users WHERE about <> '';
15
16INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
17SELECT 'org', id, about, about_format FROM orgs WHERE about <> '';
18
19ALTER TABLE users DROP COLUMN about;
20ALTER TABLE users DROP COLUMN about_format;
21ALTER TABLE orgs DROP COLUMN about;
22ALTER 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.
228type Profile struct { 230type 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".
264func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { 263func (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>.
38Write 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">