commit 58a871ccd1abb6b2692cf8c2d52070c77cde3e52
parent 4af3c3c04107f8ac9ef4a0a37a113767710c23db
Author: Damian Myrda <damian@prime8.dev>
Date: Tue, 20 Oct 2026 22:30:53 +0000
feat: read project READMEs from local bare git repos
Replace the GitLab API reader with a local git reader that shells out
to `git --git-dir=$GIT_REPOS_DIR/<repo>.git show HEAD:<readme>`
(README.md, readme.md, README, README.markdown) and takes the created
and modified dates from the first and last commit times.
Project and README links now point at the stagit pages under
GIT_WEB_BASE (default https://git.prime8.dev): <base>/<repo>/ for the
project, file/<path>.html for relative links and raw/<path> for
relative images. config.Project drops APIURL in favour of Repo.
Diffstat:
10 files changed, 404 insertions(+), 233 deletions(-)
diff --git a/cmd/server/handlers/handler.go b/cmd/server/handlers/handler.go
@@ -10,7 +10,7 @@ import (
type Handler struct {
renderer *web.Renderer
- gitLabService *external.GitLab
+ gitService *external.Git
wakaTimeService *external.WakaTime
}
@@ -24,7 +24,7 @@ func NewHandler() (*Handler, error) {
return &Handler{
renderer: renderer,
- gitLabService: external.NewGitLab(),
+ gitService: external.NewGit(),
wakaTimeService: external.NewWakaTime(c),
}, nil
}
diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go
@@ -15,7 +15,7 @@ import (
func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
about := config.GetAbout()
- projects, err := content.LoadProjects(h.gitLabService)
+ projects, err := content.LoadProjects(h.gitService)
if err != nil {
http.Error(w, "Failed to load projects", http.StatusInternalServerError)
return
@@ -38,7 +38,7 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
return
}
- project, err := content.GetProjectByID(h.gitLabService, id)
+ project, err := content.GetProjectByID(h.gitService, id)
if err != nil {
http.Error(w, "Failed to load project", http.StatusInternalServerError)
return
@@ -66,7 +66,7 @@ func applyProjectDetailData(data *web.Data, project *config.Project) {
func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
- project, err := content.GetProjectByID(h.gitLabService, id)
+ project, err := content.GetProjectByID(h.gitService, id)
if err != nil {
http.Error(w, "Failed to load project", http.StatusInternalServerError)
return
@@ -86,7 +86,9 @@ func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
}
func projectLinkLabel(raw string) string {
- if strings.Contains(raw, "git.prime8.dev") || strings.Contains(raw, "gitlab.com") {
+ if strings.HasPrefix(raw, config.GitWebBase()+"/") || strings.Contains(raw, "git.prime8.dev") {
+ return "Source"
+ } else if strings.Contains(raw, "gitlab.com") {
return "GitLab"
} else if strings.Contains(raw, "github.com") {
return "GitHub"
diff --git a/cmd/server/handlers/sitemap.go b/cmd/server/handlers/sitemap.go
@@ -17,7 +17,7 @@ func (h *Handler) SitemapHandler(w http.ResponseWriter, r *http.Request) {
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
blogs, _ := content.LoadBlogs()
- projects, _ := content.LoadProjects(h.gitLabService)
+ projects, _ := content.LoadProjects(h.gitService)
experience := config.GetExperience()
routes := []string{"/", "/projects", "/experience", "/blog"}
diff --git a/internal/config/projects.go b/internal/config/projects.go
@@ -1,16 +1,37 @@
package config
+import (
+ "os"
+ "strings"
+)
+
+const defaultGitWebBase = "https://git.prime8.dev"
+
type Project struct {
ID string
Name string
Subtitle string
Tech []string
URL string
- APIURL string
+ Repo string
Markdown string
HTML string
}
+// GitWebBase is the base URL of the stagit pages (env GIT_WEB_BASE).
+func GitWebBase() string {
+ base := strings.TrimSpace(os.Getenv("GIT_WEB_BASE"))
+ if base == "" {
+ base = defaultGitWebBase
+ }
+ return strings.TrimSuffix(base, "/")
+}
+
+// GitProjectURL is the public stagit page for a repo: <base>/<repo>/.
+func GitProjectURL(repo string) string {
+ return GitWebBase() + "/" + repo + "/"
+}
+
func GetProjects() []Project {
return []Project{
{
@@ -18,32 +39,32 @@ func GetProjects() []Project {
Name: "janitor",
Subtitle: "audio classification with ast",
Tech: []string{"rust", "cuda"},
- URL: "https://git.prime8.dev/moncheeta/janitor",
- APIURL: "https://git.prime8.dev/api/v4/projects/moncheeta%2Fjanitor",
+ URL: GitProjectURL("janitor"),
+ Repo: "janitor",
},
{
ID: "summarizer",
Name: "summarizer",
Subtitle: "audio transcription microservices",
Tech: []string{"python", "docker", "kafka"},
- URL: "https://git.prime8.dev/moncheeta/summarizer",
- APIURL: "https://git.prime8.dev/api/v4/projects/moncheeta%2Fsummarizer",
+ URL: GitProjectURL("summarizer"),
+ Repo: "summarizer",
},
{
ID: "finger",
Name: "finger",
Subtitle: "mobile auth app",
Tech: []string{"typescript", "react native", "supabase"},
- URL: "https://git.prime8.dev/moncheeta/finger",
- APIURL: "https://git.prime8.dev/api/v4/projects/moncheeta%2Ffinger",
+ URL: GitProjectURL("finger"),
+ Repo: "finger",
},
{
ID: "computer-science-club",
Name: "computer-science-club",
Subtitle: "cs club website",
Tech: []string{"python", "rest"},
- URL: "https://git.prime8.dev/moncheeta/computer-science-club",
- APIURL: "https://git.prime8.dev/api/v4/projects/moncheeta%2Fcomputer-science-club",
+ URL: GitProjectURL("computer-science-club"),
+ Repo: "computer-science-club",
},
}
}
diff --git a/internal/content/projects.go b/internal/content/projects.go
@@ -25,9 +25,9 @@ var (
projectsRefreshInProgress bool
)
-func LoadProjects(gitLabService *external.GitLab) ([]config.Project, error) {
- if gitLabService == nil {
- gitLabService = external.NewGitLab()
+func LoadProjects(gitService *external.Git) ([]config.Project, error) {
+ if gitService == nil {
+ gitService = external.NewGit()
}
if cached, found := projectsCache.Get(projectsCacheKey); found {
@@ -38,18 +38,18 @@ func LoadProjects(gitLabService *external.GitLab) ([]config.Project, error) {
projects := config.GetProjects()
projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
- startProjectsRefresh(gitLabService)
+ startProjectsRefresh(gitService)
return cloneProjects(projects), nil
}
-func GetProjectByID(gitLabService *external.GitLab, id string) (*config.Project, error) {
+func GetProjectByID(gitService *external.Git, id string) (*config.Project, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, nil
}
- projects, err := LoadProjects(gitLabService)
+ projects, err := LoadProjects(gitService)
if err != nil {
return nil, err
}
@@ -58,8 +58,8 @@ func GetProjectByID(gitLabService *external.GitLab, id string) (*config.Project,
if projects[i].ID != id {
continue
}
- if gitLabService != nil && strings.TrimSpace(projects[i].Markdown) == "" && strings.TrimSpace(projects[i].HTML) == "" {
- if readme, err := fetchProjectReadme(gitLabService, projects[i]); err == nil {
+ if gitService != nil && strings.TrimSpace(projects[i].Markdown) == "" && strings.TrimSpace(projects[i].HTML) == "" {
+ if readme, err := fetchProjectReadme(gitService, projects[i]); err == nil {
projects[i].Markdown = readme.Markdown
projects[i].HTML = readme.HTML
projectsCache.Set(projectsCacheKey, cloneProjects(projects), projectsCacheTTL)
@@ -73,10 +73,10 @@ func GetProjectByID(gitLabService *external.GitLab, id string) (*config.Project,
return nil, nil
}
-func fetchProjects(gitLabService *external.GitLab) ([]config.Project, error) {
+func fetchProjects(gitService *external.Git) ([]config.Project, error) {
projects := config.GetProjects()
for i := range projects {
- readme, err := fetchProjectReadme(gitLabService, projects[i])
+ readme, err := fetchProjectReadme(gitService, projects[i])
if err != nil {
slog.Warn("project metadata fetch failed", "project_id", projects[i].ID, "error", err)
continue
@@ -89,8 +89,8 @@ func fetchProjects(gitLabService *external.GitLab) ([]config.Project, error) {
return projects, nil
}
-func fetchProjectReadme(gitLabService *external.GitLab, project config.Project) (config.Project, error) {
- metadata, err := gitLabService.FetchMetadata(context.Background(), project)
+func fetchProjectReadme(gitService *external.Git, project config.Project) (config.Project, error) {
+ metadata, err := gitService.FetchMetadata(context.Background(), project)
if err != nil {
return config.Project{}, err
}
@@ -134,8 +134,8 @@ func cloneProjects(projects []config.Project) []config.Project {
return cloned
}
-func startProjectsRefresh(gitLabService *external.GitLab) {
- if gitLabService == nil {
+func startProjectsRefresh(gitService *external.Git) {
+ if gitService == nil {
return
}
@@ -154,7 +154,7 @@ func startProjectsRefresh(gitLabService *external.GitLab) {
projectsRefreshMu.Unlock()
}()
- projects, err := fetchProjects(gitLabService)
+ projects, err := fetchProjects(gitService)
if err != nil {
slog.Warn("project metadata background refresh failed", "error", err)
return
diff --git a/internal/external/git.go b/internal/external/git.go
@@ -0,0 +1,229 @@
+package external
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+)
+
+const (
+ defaultGitReposDir = "/srv/git/repos"
+ gitCommandTimeout = 10 * time.Second
+)
+
+// readmeNames are tried in order when reading a repository README from HEAD.
+var readmeNames = []string{"README.md", "readme.md", "README", "README.markdown"}
+
+// Git reads project metadata from local bare repositories.
+type Git struct {
+ reposDir string
+ webBase string
+}
+
+func NewGit() *Git {
+ reposDir := strings.TrimSpace(os.Getenv("GIT_REPOS_DIR"))
+ if reposDir == "" {
+ reposDir = defaultGitReposDir
+ }
+
+ return &Git{
+ reposDir: reposDir,
+ webBase: config.GitWebBase(),
+ }
+}
+
+type GitMetadata struct {
+ Readme string
+ DateCreated time.Time
+ DateModified time.Time
+}
+
+func (s *Git) FetchMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ return s.readMetadata(ctx, project)
+}
+
+func (s *Git) RefreshMetadata(ctx context.Context, project config.Project) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ _, err := s.readMetadata(ctx, project)
+ return err
+}
+
+func (s *Git) repoDir(repo string) string {
+ return filepath.Join(s.reposDir, repo+".git")
+}
+
+func (s *Git) readMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
+ repo := strings.TrimSpace(project.Repo)
+ if repo == "" {
+ return nil, fmt.Errorf("project %q has no repo configured", project.ID)
+ }
+ if strings.Contains(repo, "/") || strings.Contains(repo, "..") {
+ return nil, fmt.Errorf("invalid repo name %q", repo)
+ }
+
+ dir := s.repoDir(repo)
+ if info, err := os.Stat(dir); err != nil {
+ return nil, fmt.Errorf("repo %s: %w", repo, err)
+ } else if !info.IsDir() {
+ return nil, fmt.Errorf("repo %s: %s is not a directory", repo, dir)
+ }
+
+ modified, err := s.commitTime(ctx, dir, "log", "-1", "--format=%ct", "HEAD")
+ if err != nil {
+ return nil, fmt.Errorf("repo %s: last commit: %w", repo, err)
+ }
+ created, err := s.commitTime(ctx, dir, "log", "--reverse", "--format=%ct", "HEAD")
+ if err != nil {
+ return nil, fmt.Errorf("repo %s: first commit: %w", repo, err)
+ }
+
+ readme := ""
+ for _, name := range readmeNames {
+ out, err := s.git(ctx, dir, "show", "HEAD:"+name)
+ if err != nil {
+ continue
+ }
+ readme = fixRelativePaths(s.webBase, repo, string(out))
+ break
+ }
+
+ return &GitMetadata{
+ Readme: readme,
+ DateCreated: created,
+ DateModified: modified,
+ }, nil
+}
+
+// commitTime runs a git log command printing %ct per line and returns the
+// time on the first line.
+func (s *Git) commitTime(ctx context.Context, dir string, args ...string) (time.Time, error) {
+ out, err := s.git(ctx, dir, args...)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ line, _, _ := strings.Cut(strings.TrimSpace(string(out)), "\n")
+ line = strings.TrimSpace(line)
+ if line == "" {
+ return time.Time{}, errors.New("no commits")
+ }
+
+ sec, err := strconv.ParseInt(line, 10, 64)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("parse commit time %q: %w", line, err)
+ }
+
+ return time.Unix(sec, 0).UTC(), nil
+}
+
+func (s *Git) git(ctx context.Context, dir string, args ...string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, gitCommandTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "git", append([]string{"--git-dir=" + dir}, args...)...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ if err != nil {
+ if msg := strings.TrimSpace(stderr.String()); msg != "" {
+ return nil, fmt.Errorf("git %s: %w: %s", args[0], err, msg)
+ }
+ return nil, fmt.Errorf("git %s: %w", args[0], err)
+ }
+
+ return out, nil
+}
+
+// imageRegex and linkRegex match inline markdown images and links with an
+// optional title: [text](target "title").
+var (
+ imageRegex = regexp.MustCompile(`!\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
+ linkRegex = regexp.MustCompile(`\[([^\]]*)\]\(\s*([^)\s]+)((?:\s+"[^"]*")?)\s*\)`)
+ urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`)
+)
+
+const imagePlaceholder = "\x00img%d\x00"
+
+// fixRelativePaths rewrites repo-relative links and images in a README to the
+// stagit web layout: links to <base>/<repo>/file/<path>.html and images to
+// <base>/<repo>/raw/<path>. Absolute URLs and #anchors are left unchanged.
+func fixRelativePaths(webBase, repo, markdown string) string {
+ repoBase := strings.TrimSuffix(webBase, "/") + "/" + repo
+
+ // Images first, swapped for placeholders so that links wrapping an image
+ // ([](target)) are still matched as links afterwards.
+ images := []string{}
+ markdown = imageRegex.ReplaceAllStringFunc(markdown, func(match string) string {
+ parts := imageRegex.FindStringSubmatch(match)
+ if path, ok := repoRelativePath(parts[2]); ok {
+ match = fmt.Sprintf("", parts[1], repoBase, path, parts[3])
+ }
+ images = append(images, match)
+ return fmt.Sprintf(imagePlaceholder, len(images)-1)
+ })
+
+ markdown = linkRegex.ReplaceAllStringFunc(markdown, func(match string) string {
+ parts := linkRegex.FindStringSubmatch(match)
+ target := parts[2]
+ path, ok := repoRelativePath(target)
+ if !ok {
+ return match
+ }
+
+ var rewritten string
+ if strings.HasSuffix(path, "/") {
+ rewritten = repoBase + "/files.html"
+ } else {
+ rewritten = fmt.Sprintf("%s/file/%s.html", repoBase, path)
+ if _, fragment, found := strings.Cut(target, "#"); found && fragment != "" {
+ rewritten += "#" + fragment
+ }
+ }
+
+ return fmt.Sprintf("[%s](%s%s)", parts[1], rewritten, parts[3])
+ })
+
+ for i, image := range images {
+ markdown = strings.Replace(markdown, fmt.Sprintf(imagePlaceholder, i), image, 1)
+ }
+
+ return markdown
+}
+
+// repoRelativePath returns the cleaned repo-relative path for a markdown
+// target, or false when the target is absolute (has a scheme or is
+// protocol-relative) or is a bare #anchor.
+func repoRelativePath(target string) (string, bool) {
+ if target == "" || strings.HasPrefix(target, "#") || strings.HasPrefix(target, "//") || urlSchemeRegex.MatchString(target) {
+ return "", false
+ }
+
+ path, _, _ := strings.Cut(target, "#")
+ path, _, _ = strings.Cut(path, "?")
+ for strings.HasPrefix(path, "./") {
+ path = strings.TrimPrefix(path, "./")
+ }
+ path = strings.TrimLeft(path, "/")
+ if path == "" {
+ return "", false
+ }
+
+ return path, true
+}
diff --git a/internal/external/git_test.go b/internal/external/git_test.go
@@ -0,0 +1,111 @@
+package external
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+)
+
+func TestFixRelativePaths(t *testing.T) {
+ const base = "https://git.prime8.dev"
+ tests := []struct {
+ name, in, want string
+ }{
+ {"relative link", "[docs](docs/setup.md)", "[docs](https://git.prime8.dev/janitor/file/docs/setup.md.html)"},
+ {"dot slash link", "[lic](./LICENSE)", "[lic](https://git.prime8.dev/janitor/file/LICENSE.html)"},
+ {"link with fragment", "[x](./a.md#usage)", "[x](https://git.prime8.dev/janitor/file/a.md.html#usage)"},
+ {"relative image", "", ""},
+ {"image with title", ``, ``},
+ {"absolute link", "[site](https://prime8.dev)", "[site](https://prime8.dev)"},
+ {"http image", "", ""},
+ {"anchor", "[top](#top)", "[top](#top)"},
+ {"mailto", "[mail](mailto:damian@prime8.dev)", "[mail](mailto:damian@prime8.dev)"},
+ {
+ "linked image",
+ "[](./CONTRIBUTING.md)",
+ "[](https://git.prime8.dev/janitor/file/CONTRIBUTING.md.html)",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := fixRelativePaths(base, "janitor", tt.in); got != tt.want {
+ t.Errorf("fixRelativePaths(%q)\n got %q\n want %q", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGitFetchMetadata(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git not installed")
+ }
+
+ reposDir := t.TempDir()
+ work := t.TempDir()
+ bare := filepath.Join(reposDir, "demo.git")
+
+ run := func(env []string, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com")
+ cmd.Env = append(cmd.Env, env...)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ first := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
+ last := time.Date(2025, 6, 7, 8, 9, 10, 0, time.UTC)
+ at := func(ts time.Time) []string {
+ d := ts.Format(time.RFC3339)
+ return []string{"GIT_AUTHOR_DATE=" + d, "GIT_COMMITTER_DATE=" + d}
+ }
+
+ run(nil, work, "init", "-q")
+ if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("# demo\n\n\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(nil, work, "add", ".")
+ run(at(first), work, "commit", "-q", "-m", "first")
+ if err := os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run(nil, work, "add", ".")
+ run(at(last), work, "commit", "-q", "-m", "second")
+ run(nil, reposDir, "clone", "-q", "--bare", work, bare)
+
+ t.Setenv("GIT_REPOS_DIR", reposDir)
+ t.Setenv("GIT_WEB_BASE", "https://git.example.com/")
+ svc := NewGit()
+
+ md, err := svc.FetchMetadata(context.Background(), config.Project{ID: "demo", Repo: "demo"})
+ if err != nil {
+ t.Fatalf("FetchMetadata: %v", err)
+ }
+ if want := "# demo\n\n\n"; md.Readme != want {
+ t.Errorf("Readme = %q, want %q", md.Readme, want)
+ }
+ if !md.DateCreated.Equal(first) {
+ t.Errorf("DateCreated = %v, want %v", md.DateCreated, first)
+ }
+ if !md.DateModified.Equal(last) {
+ t.Errorf("DateModified = %v, want %v", md.DateModified, last)
+ }
+
+ if _, err := svc.FetchMetadata(context.Background(), config.Project{ID: "missing", Repo: "missing"}); err == nil {
+ t.Error("expected error for missing repo")
+ }
+ if _, err := svc.FetchMetadata(context.Background(), config.Project{ID: "none"}); err == nil {
+ t.Error("expected error for project without repo")
+ }
+}
diff --git a/internal/external/gitlab.go b/internal/external/gitlab.go
@@ -1,192 +0,0 @@
-package external
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "regexp"
- "strings"
- "time"
-
- "git.prime8.dev/moncheeta/prime8dev/internal/config"
- "git.prime8.dev/moncheeta/prime8dev/internal/utils"
-)
-
-type GitLab struct {
- httpClient *http.Client
- apiBaseURL string
-}
-
-func NewGitLab() *GitLab {
- return &GitLab{
- httpClient: &http.Client{
- Timeout: 10 * time.Second,
- },
- apiBaseURL: strings.TrimSpace(os.Getenv("GITLAB_HOST")),
- }
-}
-
-type GitMetadata struct {
- Readme string
- DateCreated time.Time
- DateModified time.Time
-}
-
-func (s *GitLab) FetchMetadata(ctx context.Context, project config.Project) (*GitMetadata, error) {
- if ctx == nil {
- ctx = context.Background()
- }
-
- return s.fetchFromGitLab(ctx, project)
-}
-
-func (s *GitLab) RefreshMetadata(ctx context.Context, project config.Project) error {
- if ctx == nil {
- ctx = context.Background()
- }
-
- _, err := s.fetchFromGitLab(ctx, project)
- return err
-}
-
-func (s *GitLab) fetchFromGitLab(ctx context.Context, project config.Project) (*GitMetadata, error) {
- apiURL := s.rewriteGitLabURL(project.APIURL)
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("User-Agent", "prime8dev-portfolio")
-
- resp, err := s.httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("gitlab API returned %d", resp.StatusCode)
- }
-
- var repo struct {
- WebURL string `json:"web_url"`
- DefaultBranch string `json:"default_branch"`
- CreatedAt string `json:"created_at"`
- LastActivityAt string `json:"last_activity_at"`
- ReadmeURL string `json:"readme_url"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
- return nil, err
- }
-
- webURL := utils.EnsureHTTPS(repo.WebURL)
-
- dateCreated, _ := time.Parse(time.RFC3339, repo.CreatedAt)
- dateModified, _ := time.Parse(time.RFC3339, repo.LastActivityAt)
-
- readme := ""
- if repo.ReadmeURL != "" {
- readmeURL := s.rewriteGitLabURL(strings.Replace(repo.ReadmeURL, "/-/blob/", "/-/raw/", 1))
- readme = s.fetchReadme(ctx, webURL, repo.DefaultBranch, readmeURL)
- }
-
- return &GitMetadata{
- Readme: readme,
- DateCreated: dateCreated,
- DateModified: dateModified,
- }, nil
-}
-
-func (s *GitLab) rewriteGitLabURL(rawURL string) string {
- if s.apiBaseURL == "" {
- return rawURL
- }
-
- base, err := url.Parse(s.apiBaseURL)
- if err != nil || base.Scheme == "" || base.Host == "" {
- return rawURL
- }
-
- target, err := url.Parse(rawURL)
- if err != nil || target.Scheme == "" || target.Host == "" {
- return rawURL
- }
-
- targetOrigin := fmt.Sprintf("%s://%s", target.Scheme, target.Host)
- suffix := strings.TrimPrefix(rawURL, targetOrigin)
- if suffix == "" {
- suffix = "/"
- }
-
- basePath := strings.TrimSuffix(base.EscapedPath(), "/")
- if basePath != "" {
- if !strings.HasPrefix(suffix, "/") {
- suffix = fmt.Sprintf("/%s", suffix)
- }
- suffix = fmt.Sprintf("%s%s", basePath, suffix)
- }
-
- return fmt.Sprintf("%s://%s%s", base.Scheme, base.Host, suffix)
-}
-
-func (s *GitLab) fetchReadme(ctx context.Context, webURL, branch, readmeURL string) string {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, readmeURL, nil)
- if err != nil {
- return ""
- }
-
- resp, err := s.httpClient.Do(req)
- if err != nil {
- return ""
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return ""
- }
-
- contentBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- return ""
- }
- content := string(contentBytes)
-
- return fixRelativePaths(webURL, branch, content)
-}
-
-var linkRegex = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
-var imageRegex = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
-
-func fixRelativePaths(webURL, branch, markdown string) string {
- blob := fmt.Sprintf("%s/-/blob/%s", webURL, branch)
- raw := fmt.Sprintf("%s/-/raw/%s", webURL, branch)
-
- markdown = imageRegex.ReplaceAllStringFunc(markdown, func(match string) string {
- parts := imageRegex.FindStringSubmatch(match)
- if len(parts) != 3 {
- return match
- }
- alt, path := parts[1], parts[2]
- if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
- return match
- }
- return fmt.Sprintf("", alt, raw, path)
- })
-
- markdown = linkRegex.ReplaceAllStringFunc(markdown, func(match string) string {
- parts := linkRegex.FindStringSubmatch(match)
- if len(parts) != 3 {
- return match
- }
- text, path := parts[1], parts[2]
- if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
- return match
- }
- return fmt.Sprintf("[%s](%s/%s)", text, blob, path)
- })
- return markdown
-}
diff --git a/internal/tui/projects_readme.go b/internal/tui/projects_readme.go
@@ -43,7 +43,7 @@ func (m *portfolioModel) maybeLoadProjectReadmeCmd() tea.Cmd {
return nil
}
m.projectReadmeLoads[id] = true
- return fetchProjectReadmeCmd(m.gitLabService, *project)
+ return fetchProjectReadmeCmd(m.gitService, *project)
}
func (m *portfolioModel) maybePrefetchProjectReadmesCmd() tea.Cmd {
@@ -63,7 +63,7 @@ func (m *portfolioModel) maybePrefetchProjectReadmesCmd() tea.Cmd {
continue
}
m.projectReadmeLoads[id] = true
- cmds = append(cmds, fetchProjectReadmeCmd(m.gitLabService, project))
+ cmds = append(cmds, fetchProjectReadmeCmd(m.gitService, project))
}
m.projectPrefetchDone = true
@@ -74,7 +74,7 @@ func (m *portfolioModel) maybePrefetchProjectReadmesCmd() tea.Cmd {
return tea.Batch(cmds...)
}
-func fetchProjectReadmeCmd(service *external.GitLab, project appconfig.Project) tea.Cmd {
+func fetchProjectReadmeCmd(service *external.Git, project appconfig.Project) tea.Cmd {
return func() tea.Msg {
current, err := appcontent.GetProjectByID(service, project.ID)
if err != nil {
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -60,10 +60,10 @@ type portfolioModel struct {
bootActive bool
bootRand *rand.Rand
- keys keyMap
- help help.Model
- viewport viewport.Model
- gitLabService *external.GitLab
+ keys keyMap
+ help help.Model
+ viewport viewport.Model
+ gitService *external.Git
projectReadmes map[string]string
projectReadmeErrors map[string]string
@@ -83,12 +83,12 @@ type portfolioModel struct {
}
func newPortfolioModel(user string) portfolioModel {
- gitLabService := external.NewGitLab()
+ gitService := external.NewGit()
about := appconfig.GetAbout()
exp := appconfig.GetExperience()
blogs, _ := appcontent.LoadBlogs()
- projects, _ := appcontent.LoadProjects(gitLabService)
+ projects, _ := appcontent.LoadProjects(gitService)
bootTitle := about.Name
if bootTitle == "" {
bootTitle = "prime8.dev"
@@ -100,7 +100,7 @@ func newPortfolioModel(user string) portfolioModel {
h.ShowAll = false
m := portfolioModel{
- gitLabService: gitLabService,
+ gitService: gitService,
user: user,
about: about,
currentJob: appconfig.CurrentJob(exp),