krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
f92774345dbb1721f44285daac94369e864dfa85
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T03:25:26Z
cmd/gitbay/main.go | 5 + e2e/profile_test.go | 73 +++++++++++ internal/control/profile.go | 158 +++++++++++++++++++++++ internal/httpd/web.go | 4 +- internal/store/migrations/0007_profiles.down.sql | 4 + internal/store/migrations/0007_profiles.up.sql | 4 + internal/store/orgs.go | 25 ++++ internal/web/templates/owner.html | 2 + 8 files changed, 274 insertions(+), 1 deletion(-) @@ -31,6 +31,10 @@ func main() { mrCmd(), webCmd(), orgCmd(), + group("profile", "user and org profiles", + pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}), + pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}), + ), webhookCmd(), remoteCmd(), initCmd(), @@ -314,6 +318,7 @@ func orgCmd() *cobra.Command { pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}), pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), + pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}), group("members", "manage members", pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), new file mode 100644 @@ -0,0 +1,73 @@ +package e2e + +import ( + "strings" + "testing" +) + +func TestOwnerProfiles(t *testing.T) { + inst := startInstance(t) + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + + // Self-service user profile; website validated. + if _, errOut, code := inst.ssh(t, aliceKey, "", + "profile", "set", "--description", "'builds small tools'", "--website", "https://alice.example"); code != 0 { + t.Fatalf("profile set: %s", errOut) + } + if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "gopher://nope"); code != 2 { + t.Fatal("bad website scheme accepted") + } + out, _, _ := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json") + if !strings.Contains(out, `"description":"builds small tools"`) || !strings.Contains(out, `"website":"https://alice.example"`) { + t.Fatalf("profile show: %s", out) + } + + // Partial update leaves the other field untouched; empty clears. + if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 { + t.Fatal("partial set failed") + } + out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json") + if !strings.Contains(out, "tinkerer") || !strings.Contains(out, "alice.example") { + t.Fatalf("partial update clobbered website: %s", out) + } + if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "''"); code != 0 { + t.Fatal("clear failed") + } + out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json") + if strings.Contains(out, "alice.example") { + t.Fatalf("website not cleared: %s", out) + } + + // Org profile: admin sets, member cannot; no flags shows. + if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "workshop"); code != 0 { + t.Fatal("org create failed") + } + if _, _, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "workshop", "bob"); code != 0 { + t.Fatal("member add failed") + } + if _, errOut, code := inst.ssh(t, aliceKey, "", + "org", "profile", "workshop", "--description", "'where things get made'", "--website", "https://workshop.example"); code != 0 { + t.Fatalf("org profile set: %s", errOut) + } + if _, _, code := inst.ssh(t, bobKey, "", "org", "profile", "workshop", "--description", "hax"); code != 4 { + t.Fatal("member set org profile") + } + out, _, _ = inst.ssh(t, bobKey, "", "org", "profile", "workshop") + if !strings.Contains(out, "where things get made") { + t.Fatalf("org profile show: %s", out) + } + + // Owner pages render description and website link. + status, body := inst.get(t, "/alice") + if status != 200 || !strings.Contains(body, "tinkerer") { + t.Fatalf("user page profile: %d", status) + } + status, body = inst.get(t, "/workshop") + if status != 200 || !strings.Contains(body, "where things get made") || + !strings.Contains(body, `href="https://workshop.example"`) { + t.Fatalf("org page profile: %d\n%s", status, body) + } +} new file mode 100644 @@ -0,0 +1,158 @@ +package control + +import ( + "errors" + "fmt" + "io" + "strings" + + "gitbay.org/gitbay/internal/protocol" + "gitbay.org/gitbay/internal/store" +) + +func init() { + register(Command{Path: []string{"profile", "show"}, + Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow}) + register(Command{Path: []string{"profile", "set"}, + Summary: "set your profile: profile set [--description <d>] [--website <url>] ('' clears)", Run: runProfileSet}) + register(Command{Path: []string{"org", "profile"}, + Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>]", Run: runOrgProfile}) +} + +// parseProfileFlags pulls --description/--website out of args; a flag given +// with an empty value clears the field, an absent flag leaves it untouched. +func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) { + for i := 0; i < len(args); i++ { + switch args[i] { + case "--description", "--website": + if i+1 >= len(args) { + return nil, nil, nil, fmt.Errorf("%s requires a value", args[i]) + } + v := args[i+1] + if args[i] == "--description" { + desc = &v + } else { + site = &v + } + i++ + default: + rest = append(rest, args[i]) + } + } + return rest, desc, site, nil +} + +func validateWebsite(url string) error { + if url == "" || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://") { + return nil + } + return errors.New("website must start with https:// or http://") +} + +func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) { + if desc != nil { + d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n") + if len(d) > 256 { + d = d[:256] + } + p.Description = d + } + if site != nil { + s := strings.TrimSpace(*site) + if err := validateWebsite(s); err != nil { + return p, err + } + p.Website = s + } + return p, nil +} + +type profileOut struct { + Name string `json:"name"` + Kind string `json:"kind"` + Description string `json:"description,omitempty"` + Website string `json:"website,omitempty"` +} + +func emitProfile(c *Ctx, d profileOut) int { + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind) + if d.Description != "" { + fmt.Fprintf(w, "%s\n", d.Description) + } + if d.Website != "" { + fmt.Fprintf(w, "%s\n", d.Website) + } + }) +} + +func runProfileShow(c *Ctx, args []string) int { + name := c.User.Username + if len(args) == 1 { + name = args[0] + } else if len(args) > 1 { + return c.fail(protocol.ExitUsage, "usage: profile show [name]") + } + kind, id := "", int64(0) + if u, err := c.Store.UserByUsername(name); err == nil { + kind, id = "user", u.ID + } else if o, err := c.Store.OrgByName(name); err == nil { + kind, id = "org", o.ID + } else { + return c.fail(protocol.ExitNotFound, "no user or organization %q", name) + } + p, err := c.Store.OwnerProfile(kind, id) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return emitProfile(c, profileOut{name, kind, p.Description, p.Website}) +} + +func runProfileSet(c *Ctx, args []string) int { + rest, desc, site, err := parseProfileFlags(args) + if err != nil || len(rest) != 0 { + return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]") + } + if desc == nil && site == nil { + return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website") + } + p, err := c.Store.OwnerProfile("user", c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + p, err = applyProfile(p, desc, site) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return emitProfile(c, profileOut{c.User.Username, "user", p.Description, p.Website}) +} + +func runOrgProfile(c *Ctx, args []string) int { + rest, desc, site, err := parseProfileFlags(args) + if err != nil || len(rest) != 1 { + return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]") + } + name := rest[0] + if desc == nil && site == nil { + return runProfileShow(c, []string{name}) + } + org, code := orgAdmin(c, name) + if code >= 0 { + return code + } + p, err := c.Store.OwnerProfile("org", org.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + p, err = applyProfile(p, desc, site) + if err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return emitProfile(c, profileOut{org.Name, "org", p.Description, p.Website}) +} @@ -181,6 +181,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + profile, _ := s.st.OwnerProfile(kind, ownerID) all, err := s.st.ListReposForOwner(kind, ownerID) if err != nil { @@ -202,10 +203,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { Viewer string Owner string Kind string + Profile store.Profile Repos []describedRepo Members []store.OrgMember Orgs []store.OrgMember - }{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs}) + }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs}) } func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { new file mode 100644 @@ -0,0 +1,4 @@ +ALTER TABLE users DROP COLUMN description; +ALTER TABLE users DROP COLUMN website; +ALTER TABLE orgs DROP COLUMN description; +ALTER TABLE orgs DROP COLUMN website; new file mode 100644 @@ -0,0 +1,4 @@ +ALTER TABLE users ADD COLUMN description TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT ''; +ALTER TABLE orgs ADD COLUMN description TEXT NOT NULL DEFAULT ''; +ALTER TABLE orgs ADD COLUMN website TEXT NOT NULL DEFAULT ''; @@ -215,3 +215,28 @@ func (s *Store) RenameOrg(orgID int64, newName string) error { } return tx.Commit() } + +// Profile is the presentational half of a user or org. +type Profile struct { + Description string + Website string +} + +// OwnerProfile reads the profile for kind "user" or "org". +func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) { + table := map[string]string{"user": "users", "org": "orgs"}[kind] + var p Profile + err := s.DB.QueryRow( + "SELECT description, website FROM "+table+" WHERE id = ?", id). + Scan(&p.Description, &p.Website) + return p, err +} + +// SetOwnerProfile updates the profile for kind "user" or "org". +func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error { + table := map[string]string{"user": "users", "org": "orgs"}[kind] + _, err := s.DB.Exec( + "UPDATE "+table+" SET description = ?, website = ? WHERE id = ?", + p.Description, p.Website, id) + return err +} @@ -1,6 +1,8 @@ {{define "title"}}{{.Owner}} · {{.Site}}{{end}} {{define "content"}} <h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1> +{{if .Profile.Description}}<p class="desc">{{.Profile.Description}}</p>{{end}} +{{if .Profile.Website}}<p class="crumbs"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}} {{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}} {{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}} <table>