Gitfed
bastien-mrq/gitfed/ Commits/ 9e9185f

Add repo description, matched by search

The roadmap assumed a description field already existed on store.Repo — it didn't. Adds it end to end: new SetRepoDescription on admin.Ops (threaded through adminrpc), editable in Settings → Visibility & topics (200 char cap, enforced server-side), shown on the repo page under the title, and matched by /search alongside name and topics.

bastien-mrq 2026-07-30 13:25 commit 9e9185f8084eedb1cc8cb7060afc61858b136cf5 parent 2b5330a545b202ada4b514236b9d5a41b2032de8
12 files changed +112 −6
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −2
M cmd/gitfed-web/handlers_repo.go +18 −1
M cmd/gitfed-web/handlers_search.go +4 −1
A cmd/gitfed-web/handlers_search_test.go +46 −0
M internal/admin/admin.go +5 −0
M internal/adminrpc/client.go +4 −0
M internal/adminrpc/protocol.go +6 −0
M internal/adminrpc/server.go +7 −0
M internal/i18n/strings_en.go +2 −0
M internal/i18n/strings_fr.go +2 −0
M internal/store/repos.go +14 −2
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 927d319..76374ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.20 + +- Repos can now have a short description (Settings → Visibility & topics, 200 chars), shown on the repo page under the title and matched by `/search` in addition to name and topics — the roadmap assumed this field already existed; it didn't, so this adds `store.Repo.Description` end to end (new `SetRepoDescription` on `admin.Ops`, threaded through `adminrpc`). + ## 1.2.19 - The nav's theme switcher is now one small icon (sun/moon/auto) that cycles system → light → dark → system, instead of a 3-option pill — more discreet, per feedback that the earlier version was too prominent. The full spelled-out choice moved to Settings → Preferences (new tab) for anyone who wants to pick explicitly rather than cycle.
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index f8bdd08..23201c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,8 +79,6 @@ celui-là la prochaine fois qu'on rouvre ce document. ### Recherche & découverte -- **Recherche de dépôts par description**, pas seulement par nom. - *(effort faible — la donnée existe déjà)*. - **Preview en direct dans la recherche** (debounce ~300ms, résultats affichés sous le champ sans changer de page) — la recherche actuelle ne fait que naviguer vers `/search`, aucun live-preview aujourd'hui.
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index e4dbe2c..d4b7537 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -126,6 +126,7 @@ var repoTpl = newTpl("repo", ` {{if .Repo.Public}}<span class="badge trusted">{{t .Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t .Lang "common.private"}}</span>{{end}} {{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}} </div> +{{if .Repo.Description}}<p class="muted">{{.Repo.Description}}</p>{{end}} <p class="muted">{{t .Lang "repo.owner"}}: {{if localUser .Repo.Owner .Domain}}<a href="/u/{{localUser .Repo.Owner .Domain}}">{{.Repo.Owner}}</a>{{else}}{{.Repo.Owner}}{{end}}</p> <div class="gf-actions-row"> @@ -479,6 +480,8 @@ var repoSettingsTpl = newTpl("repo-settings", ` <label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> {{t .Lang "repo.public_desc"}}</label> <label>{{t .Lang "repo.topics_label"}}</label> <input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go"> + <label>{{t .Lang "repo.description_label"}}</label> + <input name="description" value="{{.Description}}" maxlength="200" placeholder="{{t .Lang "repo.description_placeholder"}}"> <button type="submit">{{t .Lang "repo.save"}}</button> </form> @@ -562,12 +565,18 @@ func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) Lang string Public bool TopicsCSV string + Description string Collaborators []collaboratorView Flash template.HTML - }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), collaborators, flash(r)}) + }{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), repo.Description, collaborators, flash(r)}) s.render(w, r, name+" "+i18n.T(lang, "repo.settings_title"), "home", template.HTML(buf.String())) } +// repoDescriptionMaxLen matches the settings form's maxlength — a direct +// POST bypasses that, so this is the real enforcement (same pattern as +// bioMaxLen in handlers_settings.go). +const repoDescriptionMaxLen = 200 + func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) { name := r.PathValue("repo") lang := s.lang(r) @@ -592,6 +601,14 @@ func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) redirectWithMsg(w, r, back, err.Error(), true) return } + description := strings.TrimSpace(r.FormValue("description")) + if len(description) > repoDescriptionMaxLen { + description = description[:repoDescriptionMaxLen] + } + if err := s.ops.SetRepoDescription(name, description); err != nil { + redirectWithMsg(w, r, back, err.Error(), true) + return + } redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_saved"), false) }
cmd/gitfed-web/handlers_search.go
diff --git a/cmd/gitfed-web/handlers_search.go b/cmd/gitfed-web/handlers_search.go index 96ce45d..55e4610 100644 --- a/cmd/gitfed-web/handlers_search.go +++ b/cmd/gitfed-web/handlers_search.go @@ -19,7 +19,7 @@ var searchTpl = newTpl("search", ` <tr><th>{{t .Lang "explore.col_repo"}}</th><th>{{t .Lang "explore.col_owner"}}</th><th>{{t .Lang "explore.col_topics"}}</th></tr> {{range .Results}} <tr> - <td><a href="/r/{{.Name}}">{{.Name}}</a></td> + <td><a href="/r/{{.Name}}">{{.Name}}</a>{{if .Description}}<div class="muted" style="font-size:0.82rem;">{{.Description}}</div>{{end}}</td> <td class="muted">{{.Owner}}</td> <td>{{range .Topics}}<span class="badge plain">{{.}}</span>{{end}}</td> </tr> @@ -71,6 +71,9 @@ func matchesSearch(repo store.Repo, lowerNeedle string) bool { if strings.Contains(strings.ToLower(repo.Name), lowerNeedle) { return true } + if strings.Contains(strings.ToLower(repo.Description), lowerNeedle) { + return true + } for _, t := range repo.Topics { if strings.Contains(strings.ToLower(t), lowerNeedle) { return true
cmd/gitfed-web/handlers_search_test.go
diff --git a/cmd/gitfed-web/handlers_search_test.go b/cmd/gitfed-web/handlers_search_test.go new file mode 100644 index 0000000..df0a072 --- /dev/null +++ b/cmd/gitfed-web/handlers_search_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + + "git.neuromancer.ovh/bastien-mrq/gitfed/internal/store" +) + +func TestMatchesSearch(t *testing.T) { + repo := store.Repo{ + Name: "alice/gitfed", + Description: "A self-hosted federated git server", + Topics: []string{"go", "git"}, + } + + cases := []struct { + name string + needle string + want bool + }{ + {"matches name", "gitfed", true}, + {"matches name case-insensitively", "GITFED", true}, + {"matches description", "federated", true}, + {"matches description case-insensitively", "FEDERATED", true}, + {"matches topic", "go", true}, + {"no match", "wordpress", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := matchesSearch(repo, strings.ToLower(c.needle)); got != c.want { + t.Errorf("matchesSearch(%q) = %v, want %v", c.needle, got, c.want) + } + }) + } +} + +func TestMatchesSearchEmptyDescription(t *testing.T) { + repo := store.Repo{Name: "alice/tool"} + // An empty needle would match everything via strings.Contains — not a + // real scenario (handleSearch only calls matchesSearch when q != ""), + // but confirms an empty Description never itself causes a false match. + if matchesSearch(repo, "something") { + t.Error("expected no match against an empty description") + } +}
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index b6d4665..2cc19f0 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -52,6 +52,7 @@ type Ops interface { DeleteRepo(name string) error SetRepoPublic(name string, public bool) error SetRepoTopics(name string, topics []string) error + SetRepoDescription(name, description string) error GetRepoReadme(name string) (content string, found bool, err error) GetRepoLicense(name string) (content, filename string, found bool, err error) ListRepoTags(name string) ([]string, error) @@ -362,6 +363,10 @@ func (a *Admin) SetRepoTopics(name string, topics []string) error { return a.Store.SetRepoTopics(name, topics) } +func (a *Admin) SetRepoDescription(name, description string) error { + return a.Store.SetRepoDescription(name, description) +} + // readmeCandidates and licenseCandidates are tried in order against the // tree at HEAD; the first match wins. var readmeCandidates = []string{"README.md", "Readme.md", "README.markdown", "README", "README.txt"}
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 6aa0b47..60d9713 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -178,6 +178,10 @@ func (c *Client) SetRepoTopics(name string, topics []string) error { return c.call(methodSetRepoTopics, setTopicsArgs{Name: name, Topics: topics}, nil) } +func (c *Client) SetRepoDescription(name, description string) error { + return c.call(methodSetRepoDescription, setDescriptionArgs{Name: name, Description: description}, nil) +} + func (c *Client) GetRepoReadme(name string) (string, bool, error) { var out readmeResult err := c.call(methodGetRepoReadme, nameArgs{Name: name}, &out)
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 9c42a72..0becf84 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -33,6 +33,7 @@ const ( methodListAudit = "ListAudit" methodSetRepoPublic = "SetRepoPublic" methodSetRepoTopics = "SetRepoTopics" + methodSetRepoDescription = "SetRepoDescription" methodGetRepoReadme = "GetRepoReadme" methodGetRepoLicense = "GetRepoLicense" methodListRepoTags = "ListRepoTags" @@ -148,6 +149,11 @@ type setTopicsArgs struct { Topics []string `json:"topics"` } +type setDescriptionArgs struct { + Name string `json:"name"` + Description string `json:"description"` +} + type readmeResult struct { Content string `json:"content"` Found bool `json:"found"`
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index a9fe3dc..58e3b93 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -188,6 +188,13 @@ func (s *Server) dispatch(req wireRequest) (any, error) { } return nil, s.ops.SetRepoTopics(a.Name, a.Topics) + case methodSetRepoDescription: + var a setDescriptionArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + return nil, s.ops.SetRepoDescription(a.Name, a.Description) + case methodGetRepoReadme: var a nameArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index aba7203..f5d3d37 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -296,6 +296,8 @@ var en = map[string]string{ "repo.visibility_topics": "Visibility & topics", "repo.public_desc": "Public (readable by any authenticated principal)", "repo.topics_label": "Topics (comma-separated)", + "repo.description_label": "Description", + "repo.description_placeholder": "What is this repo for?", "repo.save": "Save", "repo.col_principal": "Principal", "repo.col_role": "Role",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index a1571f6..c2d0530 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -296,6 +296,8 @@ var fr = map[string]string{ "repo.visibility_topics": "Visibilité et sujets", "repo.public_desc": "Public (lisible par tout principal authentifié)", "repo.topics_label": "Sujets (séparés par des virgules)", + "repo.description_label": "Description", + "repo.description_placeholder": "À quoi sert ce dépôt ?", "repo.save": "Enregistrer", "repo.col_principal": "Principal", "repo.col_role": "Rôle",
internal/store/repos.go
diff --git a/internal/store/repos.go b/internal/store/repos.go index 9a48741..081802a 100644 --- a/internal/store/repos.go +++ b/internal/store/repos.go @@ -20,8 +20,9 @@ type Repo struct { // still needs a valid local account or a certificate from a domain // this instance already trusts (§5.2); it only skips the per-repo // collaborator check for reads. - Public bool `json:"public"` - Topics []string `json:"topics,omitempty"` + Public bool `json:"public"` + Topics []string `json:"topics,omitempty"` + Description string `json:"description,omitempty"` } func (s *Store) CreateRepo(r Repo) error { @@ -82,3 +83,14 @@ func (s *Store) SetRepoTopics(name string, topics []string) error { return putJSON(tx, bucketRepos, name, r) }) } + +func (s *Store) SetRepoDescription(name, description string) error { + return s.db.Update(func(tx *bolt.Tx) error { + var r Repo + if err := getJSON(tx, bucketRepos, name, &r); err != nil { + return err + } + r.Description = description + return putJSON(tx, bucketRepos, name, r) + }) +}