prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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:
Mcmd/server/handlers/handler.go | 4++--
Mcmd/server/handlers/projects.go | 10++++++----
Mcmd/server/handlers/sitemap.go | 2+-
Minternal/config/projects.go | 39++++++++++++++++++++++++++++++---------
Minternal/content/projects.go | 30+++++++++++++++---------------
Ainternal/external/git.go | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/external/git_test.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/external/gitlab.go | 192-------------------------------------------------------------------------------
Minternal/tui/projects_readme.go | 6+++---
Minternal/tui/types.go | 14+++++++-------
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 + // ([![badge](img.svg)](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("![%s](%s/raw/%s%s)", 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", "![logo](./assets/logo.png)", "![logo](https://git.prime8.dev/janitor/raw/assets/logo.png)"}, + {"image with title", `![logo](logo.png "Logo")`, `![logo](https://git.prime8.dev/janitor/raw/logo.png "Logo")`}, + {"absolute link", "[site](https://prime8.dev)", "[site](https://prime8.dev)"}, + {"http image", "![b](http://example.com/b.svg)", "![b](http://example.com/b.svg)"}, + {"anchor", "[top](#top)", "[top](#top)"}, + {"mailto", "[mail](mailto:damian@prime8.dev)", "[mail](mailto:damian@prime8.dev)"}, + { + "linked image", + "[![ci](./badge.svg)](./CONTRIBUTING.md)", + "[![ci](https://git.prime8.dev/janitor/raw/badge.svg)](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![shot](./shot.png)\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![shot](https://git.example.com/demo/raw/shot.png)\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("![%s](%s/%s)", 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),