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.
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
+ })
+}