cerrado @ 02614b3781f6acdfc6df0e7b07d856b2779c4ac7

feat: Per repository configuration
  1diff --git a/config.example.scfg b/config.example.scfg
  2index 1e3180f17834707170c018db8e31909eabda4cfe..3961e51ddb60a7deb021ad48db74525d2eae913e 100644
  3--- a/config.example.scfg
  4+++ b/config.example.scfg
  5@@ -3,6 +3,12 @@ scan /srv/git/ {
  6     public true
  7 }
  8 
  9+repository /srv/git/cerrado.git {
 10+    name cerrado
 11+    description "Self host single person forge"
 12+    public true
 13+}
 14+
 15 # TBD
 16 #user admin:iKlvHe1g0UoXE
 17 #
 18@@ -15,9 +21,3 @@ #    authentication plain
 19 #    default false
 20 #}
 21 #
 22-#repository cerrado {
 23-#    title Cerrado
 24-#    description "Self host single person readonly forge"
 25-#    list main
 26-#    public true
 27-#}
 28diff --git a/pkg/config/config.go b/pkg/config/config.go
 29index 419d49dac3a06f7357de72162885c2b822be8e07..3e539f7091a7b2b1e39ee96b7bd54136d052fd2b 100644
 30--- a/pkg/config/config.go
 31+++ b/pkg/config/config.go
 32@@ -6,6 +6,7 @@ 	"fmt"
 33 	"io"
 34 	"os"
 35 	"path"
 36+	"path/filepath"
 37 	"strconv"
 38 
 39 	"git.gabrielgio.me/cerrado/pkg/u"
 40@@ -13,8 +14,9 @@ 	"git.sr.ht/~emersion/go-scfg"
 41 )
 42 
 43 var (
 44-	ScanPathErr = errors.New("Scan path does not exist")
 45-	RepoPathErr = errors.New("Repository path does not exist")
 46+	ScanPathErr        = errors.New("Scan path does not exist")
 47+	RepoPathErr        = errors.New("Repository path does not exist")
 48+	InvalidPropertyErr = errors.New("Invalid property")
 49 )
 50 
 51 type (
 52@@ -26,16 +28,19 @@ 		Public bool
 53 	}
 54 
 55 	// configuration represents file configuration.
 56+	// fields needs to be exported to cmp to work
 57 	configuration struct {
 58-		Scan       *scan
 59-		RootReadme string
 60+		Scan         *scan
 61+		RootReadme   string
 62+		Repositories []*GitRepositoryConfiguration
 63 	}
 64 
 65 	// This is a per repository configuration.
 66 	GitRepositoryConfiguration struct {
 67-		Name   string
 68-		Path   string
 69-		Public bool
 70+		Name        string
 71+		Path        string
 72+		Description string
 73+		Public      bool
 74 	}
 75 
 76 	// ConfigurationRepository represents the configuration repository (as in
 77@@ -60,13 +65,17 @@ 		return nil, err
 78 	}
 79 
 80 	repo := &ConfigurationRepository{
 81-		rootReadme: config.RootReadme,
 82+		rootReadme:   config.RootReadme,
 83+		repositories: config.Repositories,
 84 	}
 85 
 86-	err = repo.expandOnScanPath(config.Scan.Path, config.Scan.Public)
 87-	if err != nil {
 88-		return nil, err
 89+	if config.Scan.Path != "" {
 90+		err = repo.expandOnScanPath(config.Scan.Path, config.Scan.Public)
 91+		if err != nil {
 92+			return nil, err
 93+		}
 94 	}
 95+
 96 	return repo, nil
 97 
 98 }
 99@@ -104,22 +113,32 @@ 	if err != nil {
100 		return err
101 	}
102 
103-	c.repositories = make([]*GitRepositoryConfiguration, 0)
104 	for _, e := range entries {
105 		if !e.IsDir() {
106 			continue
107 		}
108 
109 		fullPath := path.Join(scanPath, e.Name())
110-		c.repositories = append(c.repositories, &GitRepositoryConfiguration{
111-			Name:   e.Name(),
112-			Path:   fullPath,
113-			Public: public,
114-		})
115+		if !c.repoExits(fullPath) {
116+			c.repositories = append(c.repositories, &GitRepositoryConfiguration{
117+				Name:   e.Name(),
118+				Path:   fullPath,
119+				Public: public,
120+			})
121+		}
122 	}
123 	return nil
124 }
125 
126+func (c *ConfigurationRepository) repoExits(path string) bool {
127+	for _, r := range c.repositories {
128+		if path == r.Path {
129+			return true
130+		}
131+	}
132+	return false
133+}
134+
135 func parse(r io.Reader) (*configuration, error) {
136 	block, err := scfg.Read(r)
137 	if err != nil {
138@@ -138,16 +157,82 @@ 	if err != nil {
139 		return nil, err
140 	}
141 
142+	err = setRepositories(block, &config.Repositories)
143+	if err != nil {
144+		return nil, err
145+	}
146+
147 	return config, nil
148 }
149 
150+func setRepositories(block scfg.Block, repositories *[]*GitRepositoryConfiguration) error {
151+	blocks := block.GetAll("repository")
152+
153+	for _, r := range blocks {
154+		if len(r.Params) != 1 {
155+			return fmt.Errorf(
156+				"Invlid number of params for repository: %w",
157+				InvalidPropertyErr,
158+			)
159+		}
160+
161+		path := u.FirstOrZero(r.Params)
162+		repository := defaultRepisotryConfiguration(path)
163+
164+		for _, d := range r.Children {
165+			// under repository there is only single param properties
166+			if len(d.Params) != 1 {
167+				return fmt.Errorf(
168+					"Invlid number of params for %s: %w",
169+					d.Name,
170+					InvalidPropertyErr,
171+				)
172+			}
173+
174+			switch d.Name {
175+			case "name":
176+				if err := setString(d, &repository.Name); err != nil {
177+					return err
178+				}
179+			case "description":
180+				if err := setString(d, &repository.Description); err != nil {
181+					return err
182+				}
183+			case "public":
184+				if err := setBool(d, &repository.Public); err != nil {
185+					return err
186+				}
187+			}
188+		}
189+
190+		*repositories = append(*repositories, repository)
191+	}
192+
193+	return nil
194+}
195+
196 func defaultConfiguration() *configuration {
197 	return &configuration{
198-		Scan: &scan{
199-			Public: true,
200-			Path:   "",
201-		},
202-		RootReadme: "",
203+		Scan:         defaultScan(),
204+		RootReadme:   "",
205+		Repositories: make([]*GitRepositoryConfiguration, 0),
206+	}
207+}
208+
209+func defaultScan() *scan {
210+	return &scan{
211+		Public: false,
212+		Path:   "",
213+	}
214+
215+}
216+
217+func defaultRepisotryConfiguration(path string) *GitRepositoryConfiguration {
218+	return &GitRepositoryConfiguration{
219+		Path:        path,
220+		Name:        filepath.Base(path),
221+		Description: "",
222+		Public:      false,
223 	}
224 }
225 
226@@ -158,6 +243,9 @@ }
227 
228 func setScan(block scfg.Block, scan *scan) error {
229 	scanDir := block.Get("scan")
230+	if scanDir == nil {
231+		return nil
232+	}
233 	err := setString(scanDir, &scan.Path)
234 	if err != nil {
235 		return err
236@@ -182,7 +270,7 @@ }
237 
238 func setString(dir *scfg.Directive, field *string) error {
239 	if dir != nil {
240-		*field, _ = u.First(dir.Params)
241+		*field = u.FirstOrZero(dir.Params)
242 	}
243 	return nil
244 }
245diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
246index 7afbaef263f86b987b586cc5cb70cc6488a2d994..9109ecb47e242baa141860f478524b2299f33d8a 100644
247--- a/pkg/config/config_test.go
248+++ b/pkg/config/config_test.go
249@@ -19,21 +19,94 @@ 			name:   "minimal scan",
250 			config: `scan "/srv/git"`,
251 			expectedConfig: &configuration{
252 				Scan: &scan{
253-					Public: true,
254+					Public: false,
255 					Path:   "/srv/git",
256 				},
257+				Repositories: []*GitRepositoryConfiguration{},
258 			},
259 		},
260 		{
261 			name: "complete scan",
262-			config: `scan "/srv/git" {
263-	public false
264+			config: `
265+scan "/srv/git" {
266+	public true
267 }`,
268 			expectedConfig: &configuration{
269 				Scan: &scan{
270-					Public: false,
271+					Public: true,
272 					Path:   "/srv/git",
273 				},
274+				Repositories: []*GitRepositoryConfiguration{},
275+			},
276+		},
277+		{
278+			name:   "minimal repository",
279+			config: `repository /srv/git/cerrado.git`,
280+			expectedConfig: &configuration{
281+				Scan: defaultScan(),
282+				Repositories: []*GitRepositoryConfiguration{
283+					{
284+						Name:        "cerrado.git",
285+						Path:        "/srv/git/cerrado.git",
286+						Description: "",
287+						Public:      false,
288+					},
289+				},
290+			},
291+		},
292+		{
293+			name: "complete repository",
294+			config: `
295+repository /srv/git/cerrado.git {
296+	name cerrado
297+	description "Single person forge"
298+	public true
299+}`,
300+			expectedConfig: &configuration{
301+				Scan: defaultScan(),
302+				Repositories: []*GitRepositoryConfiguration{
303+					{
304+						Name:        "cerrado",
305+						Path:        "/srv/git/cerrado.git",
306+						Description: "Single person forge",
307+						Public:      true,
308+					},
309+				},
310+			},
311+		},
312+		{
313+			name: "complete",
314+			config: `
315+scan "/srv/git" {
316+	public true
317+}
318+
319+repository /srv/git/linux.git
320+
321+repository /srv/git/cerrado.git {
322+	name cerrado
323+	description "Single person forge"
324+	public true
325+}`,
326+			expectedConfig: &configuration{
327+				Scan: &scan{
328+					Public: true,
329+					Path:   "/srv/git",
330+				},
331+				Repositories: []*GitRepositoryConfiguration{
332+					{
333+						Name:        "linux.git",
334+						Path:        "/srv/git/linux.git",
335+						Description: "",
336+						Public:      false,
337+					},
338+					{
339+						Name:        "cerrado",
340+						Path:        "/srv/git/cerrado.git",
341+						Description: "Single person forge",
342+						Public:      true,
343+					},
344+				},
345 			},
346 		},
347 	}
348@@ -49,7 +122,6 @@
349 			if diff := cmp.Diff(tc.expectedConfig, config); diff != "" {
350 				t.Errorf("Wrong result given - wanted + got\n %s", diff)
351 			}
352-
353 		})
354 
355 	}
356diff --git a/pkg/service/git.go b/pkg/service/git.go
357index 94e2adc5ab4433d9cc113f7a83d7c49aafd12787..7418d971dffc7b2ff26d9ab900e94d79bba2b75a 100644
358--- a/pkg/service/git.go
359+++ b/pkg/service/git.go
360@@ -16,7 +16,6 @@
361 type (
362 	Repository struct {
363 		Name           string
364-		Title          string
365 		Description    string
366 		LastCommitDate string
367 		Ref            string
368@@ -48,8 +47,8 @@
369 func (g *GitService) ListRepositories() ([]*Repository, error) {
370 	rs := g.configRepo.List()
371 
372-	repos := make([]*Repository, len(rs))
373-	for i, r := range rs {
374+	repos := make([]*Repository, 0, len(rs))
375+	for _, r := range rs {
376 		repo, err := git.OpenRepository(r.Path)
377 		if err != nil {
378 			return nil, err
379@@ -57,12 +56,14 @@ 		}
380 
381 		obj, err := repo.LastCommit()
382 		if err != nil {
383-			return nil, err
384+			slog.Error("Error fetching last commit", "repository", r.Path, "error", err)
385+			continue
386 		}
387 
388 		head, err := repo.Head()
389 		if err != nil {
390-			return nil, err
391+			slog.Error("Error fetching head", "repository", r.Path, "error", err)
392+			continue
393 		}
394 
395 		d := path.Join(r.Path, "description")
396@@ -75,14 +76,12 @@ 				slog.Error("Error loading description file", "err", err)
397 			}
398 		}
399 
400-		baseName := path.Base(r.Path)
401-		repos[i] = &Repository{
402-			Name:           baseName,
403-			Title:          baseName,
404+		repos = append(repos, &Repository{
405+			Name:           r.Name,
406 			Description:    description,
407 			LastCommitDate: obj.Author.When.Format(timeFormat),
408 			Ref:            head.Name().Short(),
409-		}
410+		})
411 	}
412 
413 	return repos, nil
414diff --git a/pkg/u/list.go b/pkg/u/list.go
415index cf71909e439d7726b4c6d9c6fcea771ad776a5bf..7271ef3783e892aad55ea49786515f491af30735 100644
416--- a/pkg/u/list.go
417+++ b/pkg/u/list.go
418@@ -16,6 +16,14 @@ 	}
419 	return v[0]
420 }
421 
422+func Map[T any, V any](ts []T, fun func(T) V) []V {
423+	rs := make([]V, len(ts))
424+	for i := range ts {
425+		rs[i] = fun(ts[i])
426+	}
427+	return rs
428+}
429+
430 func ChunkBy[T any](items []T, chunkSize int) [][]T {
431 	var chunks = make([][]T, 0, (len(items)/chunkSize)+1)
432 	for chunkSize < len(items) {
433diff --git a/pkg/u/list_test.go b/pkg/u/list_test.go
434index 805a2091b59ce4c644f748b4b6c49a28aec50cdb..3a856b961818256ffdd0842824879169af645f7b 100644
435--- a/pkg/u/list_test.go
436+++ b/pkg/u/list_test.go
437@@ -3,6 +3,7 @@
438 package u
439 
440 import (
441+	"strconv"
442 	"testing"
443 
444 	"github.com/google/go-cmp/cmp"
445@@ -129,3 +130,32 @@
446 		})
447 	}
448 }
449+
450+func TestMap(t *testing.T) {
451+	testCases := []struct {
452+		name string
453+		in   []int
454+		out  []string
455+	}{
456+		{
457+			name: "empty",
458+			in:   []int{},
459+			out:  []string{},
460+		},
461+		{
462+			name: "not empty",
463+			in:   []int{1, 2, 3},
464+			out:  []string{"1", "2", "3"},
465+		},
466+	}
467+
468+	for _, tc := range testCases {
469+		t.Run(tc.name, func(t *testing.T) {
470+			out := Map(tc.in, func(v int) string { return strconv.Itoa(v) })
471+
472+			if diff := cmp.Diff(tc.out, out); diff != "" {
473+				t.Errorf("Map error:\n%s", diff)
474+			}
475+		})
476+	}
477+}
478diff --git a/templates/base.qtpl b/templates/base.qtpl
479index 9b0c4f57081d1861c77a7a75a4b25cafef57b6f4..ae9f7a6c50f072bfc14a2edf7c1af335f19e0a1b 100644
480--- a/templates/base.qtpl
481+++ b/templates/base.qtpl
482@@ -42,7 +42,7 @@ <html lang="en">
483     <head>
484         <meta charset="utf-8">
485         <link rel="icon" href="data:,">
486-        <title>cerrado | {%= p.Title() %}</title> 
487+        <title>{%= p.Title() %}</title> 
488         <link rel="stylesheet" href="/static/main{%s Slug%}.css">
489         <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
490         <meta name="viewport" content="width=device-width, initial-scale=1" />
491diff --git a/templates/base.qtpl.go b/templates/base.qtpl.go
492index d2bcc7369a0587ee3edf2f641a333666d598e33e..bc40252cea03af8df732d1a1b29d11716260bca4 100644
493--- a/templates/base.qtpl.go
494+++ b/templates/base.qtpl.go
495@@ -87,7 +87,7 @@ <html lang="en">
496     <head>
497         <meta charset="utf-8">
498         <link rel="icon" href="data:,">
499-        <title>cerrado | `)
500+        <title>`)
501 //line base.qtpl:45
502 	p.StreamTitle(qw422016)
503 //line base.qtpl:45