#22 Link the About page's commit hash to the commit

Слито
bajdev слито 2 коммит(ов) из bajdev/feature/commit-link в bajdev/main 13 часов назад

+ 3 - 1
README.md

@@ -63,7 +63,8 @@ and take effect without a restart.
 ```json
 ```json
 {
 {
   "homeText": "Home",
   "homeText": "Home",
-  "defaultAuthor": "Claude"
+  "defaultAuthor": "Claude",
+  "repoURL": "https://git.monoaf.com/bajdev/posts"
 }
 }
 ```
 ```
 
 
@@ -71,6 +72,7 @@ and take effect without a restart.
 |---|---|---|
 |---|---|---|
 | `homeText` | `Home` | Text of the header link to the index page |
 | `homeText` | `Home` | Text of the header link to the index page |
 | `defaultAuthor` | `Claude` | Author shown for posts without an `author` line |
 | `defaultAuthor` | `Claude` | Author shown for posts without an `author` line |
+| `repoURL` | `https://git.monoaf.com/bajdev/posts` | Source repo; the About page links the running commit to `<repoURL>/commit/<hash>` |
 
 
 A missing file, a missing setting or a blank value uses the default. If the
 A missing file, a missing setting or a blank value uses the default. If the
 file is invalid JSON or has an unknown setting name, the error is logged and
 file is invalid JSON or has an unknown setting name, the error is logged and

+ 4 - 0
config.go

@@ -15,6 +15,7 @@ import (
 type siteConfig struct {
 type siteConfig struct {
 	HomeText      string `json:"homeText"`      // text of the header link to the index
 	HomeText      string `json:"homeText"`      // text of the header link to the index
 	DefaultAuthor string `json:"defaultAuthor"` // author of posts that don't name one
 	DefaultAuthor string `json:"defaultAuthor"` // author of posts that don't name one
+	RepoURL       string `json:"repoURL"`       // web address of the source repo, for commit links
 }
 }
 
 
 // withDefaults fills in any setting that is missing or blank.
 // withDefaults fills in any setting that is missing or blank.
@@ -22,6 +23,9 @@ func (c siteConfig) withDefaults() siteConfig {
 	if strings.TrimSpace(c.HomeText) == "" {
 	if strings.TrimSpace(c.HomeText) == "" {
 		c.HomeText = "Home"
 		c.HomeText = "Home"
 	}
 	}
+	if strings.TrimSpace(c.RepoURL) == "" {
+		c.RepoURL = "https://git.monoaf.com/bajdev/posts"
+	}
 	if strings.TrimSpace(c.DefaultAuthor) == "" {
 	if strings.TrimSpace(c.DefaultAuthor) == "" {
 		c.DefaultAuthor = "Claude"
 		c.DefaultAuthor = "Claude"
 	}
 	}

+ 11 - 6
main.go

@@ -22,7 +22,7 @@ type app struct {
 	about    template.HTML
 	about    template.HTML
 	siteURL  string // absolute base URL, no trailing slash
 	siteURL  string // absolute base URL, no trailing slash
 	siteName string
 	siteName string
-	commit   string
+	commit   commitInfo
 
 
 	releaseFS   fs.FS // pull-releases/
 	releaseFS   fs.FS // pull-releases/
 	releases    []*Release
 	releases    []*Release
@@ -141,12 +141,17 @@ func (a *app) aboutPage(w http.ResponseWriter, r *http.Request) {
 		a.serverError(w, err)
 		a.serverError(w, err)
 		return
 		return
 	}
 	}
+	var commitURL string
+	if a.commit.Full != "" && !a.commit.Dirty {
+		commitURL = strings.TrimRight(a.config.Get().RepoURL, "/") + "/commit/" + a.commit.Full
+	}
 	a.render(w, http.StatusOK, "about", pageData{
 	a.render(w, http.StatusOK, "about", pageData{
-		Posts:    idx.posts,
-		Releases: a.releases,
-		Version:  version,
-		Commit:   a.commit,
-		About:    a.about,
+		Posts:     idx.posts,
+		Releases:  a.releases,
+		Version:   version,
+		Commit:    a.commit,
+		CommitURL: commitURL,
+		About:     a.about,
 	})
 	})
 }
 }
 
 

+ 46 - 0
main_test.go

@@ -304,6 +304,52 @@ func TestHeaderAndFooter(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestAboutCommitLink(t *testing.T) {
+	const full = "d2a387e186c8caf21a35349b8c3d60f68fb87b1f"
+	about := func(t *testing.T, c commitInfo, siteJSON string) string {
+		t.Helper()
+		dir := t.TempDir()
+		if siteJSON != "" {
+			writeFile(t, dir, "site.json", siteJSON)
+		}
+		a, err := newApp(dir, "https://posts.example.com")
+		if err != nil {
+			t.Fatal(err)
+		}
+		a.commit = c // go test binaries carry no VCS stamp; set one
+		srv := httptest.NewServer(a.routes())
+		t.Cleanup(srv.Close)
+		_, _, body := get(t, srv, "/about")
+		return body
+	}
+
+	t.Run("clean build links the full hash", func(t *testing.T) {
+		body := about(t, commitInfo{Full: full, Short: full[:7]}, "")
+		want := `<span class="muted">(<a href="https://git.monoaf.com/bajdev/posts/commit/` + full + `">d2a387e</a>)</span>`
+		if !strings.Contains(body, want) {
+			t.Errorf("missing %s in:\n%s", want, body)
+		}
+	})
+	t.Run("repoURL from site.json", func(t *testing.T) {
+		body := about(t, commitInfo{Full: full, Short: full[:7]}, `{"repoURL": "https://example.org/me/fork/"}`)
+		if !strings.Contains(body, `href="https://example.org/me/fork/commit/`+full+`"`) {
+			t.Errorf("site.json repoURL not used:\n%s", body)
+		}
+	})
+	t.Run("dirty build is not linked", func(t *testing.T) {
+		body := about(t, commitInfo{Full: full, Short: full[:7], Dirty: true}, "")
+		if !strings.Contains(body, `(d2a387e-dirty)`) || strings.Contains(body, "/commit/") {
+			t.Errorf("dirty build should show -dirty with no link:\n%s", body)
+		}
+	})
+	t.Run("no commit shows version only", func(t *testing.T) {
+		body := about(t, commitInfo{}, "")
+		if !strings.Contains(body, "<dd>"+version+"</dd>") {
+			t.Errorf("want bare version:\n%s", body)
+		}
+	})
+}
+
 func TestAboutPage(t *testing.T) {
 func TestAboutPage(t *testing.T) {
 	srv, _ := newTestServer(t, sample)
 	srv, _ := newTestServer(t, sample)
 	status, _, body := get(t, srv, "/about")
 	status, _, body := get(t, srv, "/about")

+ 36 - 0
pull-releases/010-commit-link/README.md

@@ -0,0 +1,36 @@
+# Link the About page's commit hash to the commit
+
+Branch `feature/commit-link` · version 0.5.3
+
+## What changed
+
+- **Linked hash:** the About page's build hash, e.g. `0.5.3 (d2a387e)`, now links to that commit in Gogs. The link uses the full hash: `https://git.monoaf.com/bajdev/posts/commit/<full hash>`.
+- **New `repoURL` setting:** optional, in `content/site.json`, defaulting to `https://git.monoaf.com/bajdev/posts`. Anyone running a fork of the code can point the link at their own repo.
+- **Dirty builds aren't linked.** A binary built from uncommitted changes matches no commit, so it still shows `(abc1234-dirty)` as plain text. Pipeline builds always come from a clean checkout, so the live site always has the link.
+- **README:** documents `repoURL`.
+- **Version:** 0.5.3.
+
+## Notes
+
+- Since Phase 7, the live site is always built from the tip of `main`, so the link goes to the merge commit that's running.
+- The hash display is unchanged; only the link is new.
+
+## Testing
+
+- All tests pass with the race detector (`go test -race ./...`).
+- New test covering four cases: a clean build links the full hash, `repoURL` in `site.json` overrides the link, a dirty build shows `-dirty` with no link, and no commit at all shows the bare version.
+- Built from a clean local commit and checked the About page HTML: the link went to `/commit/<full hash>` of that build. Gogs commit URLs return 200 for the full hash.
+
+| About page |
+|---|
+| [![About page](about-link.png)](about-link.png) |
+
+## Screenshots
+
+From the branch's build running locally. The home page doesn't change.
+
+| Desktop | Mobile |
+|---|---|
+| [![Desktop](desktop.png)](desktop.png) | [![Mobile](mobile.png)](mobile.png) |
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)

BIN
pull-releases/010-commit-link/about-link.png


BIN
pull-releases/010-commit-link/desktop.png


BIN
pull-releases/010-commit-link/mobile.png


+ 1 - 0
pull-releases/README.md

@@ -36,3 +36,4 @@ screenshot, or its `README.md` doesn't load.
 | [007](007-pinned-posts/) | Pinned posts |
 | [007](007-pinned-posts/) | Pinned posts |
 | [008](008-tab-size/) | Tabs in code blocks 4 columns wide |
 | [008](008-tab-size/) | Tabs in code blocks 4 columns wide |
 | [009](009-pager-order/) | Previous post on the left, next on the right |
 | [009](009-pager-order/) | Previous post on the left, next on the right |
+| [010](010-commit-link/) | About page commit hash links to the commit |

+ 4 - 2
render.go

@@ -67,8 +67,10 @@ type pageData struct {
 	Message      string
 	Message      string
 
 
 	// About page only.
 	// About page only.
-	Version, Commit string
-	About           template.HTML
+	Version   string
+	Commit    commitInfo
+	CommitURL string // empty when the build doesn't match a commit exactly
+	About     template.HTML
 
 
 	// Release notes pages (and the About page's link to them).
 	// Release notes pages (and the About page's link to them).
 	Releases []*Release
 	Releases []*Release

+ 1 - 1
templates/about.html

@@ -4,7 +4,7 @@
   <h1>About</h1>
   <h1>About</h1>
   <dl class="facts">
   <dl class="facts">
     <dt>Version</dt>
     <dt>Version</dt>
-    <dd>{{.Version}}{{with .Commit}} <span class="muted">({{.}})</span>{{end}}</dd>
+    <dd>{{.Version}}{{with .Commit.Short}} <span class="muted">({{if $.CommitURL}}<a href="{{$.CommitURL}}">{{.}}</a>{{else}}{{.}}{{if $.Commit.Dirty}}-dirty{{end}}{{end}})</span>{{end}}</dd>
     <dt>Release notes</dt>
     <dt>Release notes</dt>
     <dd><a href="/releases">{{len .Releases}} releases</a></dd>
     <dd><a href="/releases">{{len .Releases}} releases</a></dd>
     <dt>Posts</dt>
     <dt>Posts</dt>

+ 19 - 16
version.go

@@ -4,31 +4,34 @@ import "runtime/debug"
 
 
 // version is the release number shown on the About page. Bump it in the PR
 // version is the release number shown on the About page. Bump it in the PR
 // that makes a user-visible change.
 // that makes a user-visible change.
-const version = "0.5.2"
+const version = "0.5.3"
 
 
-// buildCommit returns the short git revision that `go build` stamped into the
-// binary, with "-dirty" if the tree had uncommitted changes, or "" if the
-// binary was built outside a git checkout.
-func buildCommit() string {
+// commitInfo is the git revision `go build` stamped into the binary. Full is
+// empty when the binary was built outside a git checkout (for example by
+// `go test`); Dirty means the tree had uncommitted changes, so the build
+// doesn't match any commit exactly.
+type commitInfo struct {
+	Full, Short string
+	Dirty       bool
+}
+
+func buildCommit() commitInfo {
 	info, ok := debug.ReadBuildInfo()
 	info, ok := debug.ReadBuildInfo()
 	if !ok {
 	if !ok {
-		return ""
+		return commitInfo{}
 	}
 	}
-	var rev string
-	var dirty bool
+	var c commitInfo
 	for _, s := range info.Settings {
 	for _, s := range info.Settings {
 		switch s.Key {
 		switch s.Key {
 		case "vcs.revision":
 		case "vcs.revision":
-			rev = s.Value
+			c.Full = s.Value
 		case "vcs.modified":
 		case "vcs.modified":
-			dirty = s.Value == "true"
+			c.Dirty = s.Value == "true"
 		}
 		}
 	}
 	}
-	if len(rev) > 7 {
-		rev = rev[:7]
-	}
-	if rev != "" && dirty {
-		rev += "-dirty"
+	c.Short = c.Full
+	if len(c.Short) > 7 {
+		c.Short = c.Short[:7]
 	}
 	}
-	return rev
+	return c
 }
 }