feat(web): display co-authors in commit views

Amolith and Crush created

Parse Co-authored-by trailers from commit messages and display
all contributors with proper grammar in commit and commits
pages. Authorship now reads "Author and Co-author" for one
co-author or "Author, Co1, Co2, and Co3" for multiple.

Implements: bug-7e8823e
Co-authored-by: Crush <crush@charm.land>

Change summary

pkg/web/templates/commit.html  | 12 ++++
pkg/web/templates/commits.html | 10 +++
pkg/web/util.go                | 98 ++++++++++++++++++++++++++++++++++++
pkg/web/webui.go               | 57 ++++++++++++++++++++
4 files changed, 175 insertions(+), 2 deletions(-)

Detailed changes

pkg/web/templates/commit.html 🔗

@@ -9,7 +9,17 @@
 <header>
   <hgroup>
     <h2 id="commit-heading">{{.Commit.Message | commitSubject}}</h2>
-    <p><strong>{{.Commit.Author.Name}}</strong> created <time datetime="{{.Commit.Author.When | rfc3339}}" data-tooltip="{{.Commit.Author.When | formatDate}}">{{.Commit.Author.When | relativeTime}}</time></p>
+    <p>
+      {{ $names := .Commit | attributionNames }}
+      {{ $nlen := len $names }}
+      {{ range $i, $n := $names }}
+        {{ if gt $i 0 }}
+          {{ if eq $nlen 2 }} and {{ else if eq $i (sub $nlen 1) }}, and {{ else }}, {{ end }}
+        {{ end }}
+        <strong>{{$n}}</strong>
+      {{ end }}
+      created <time datetime="{{.Commit.Author.When | rfc3339}}" data-tooltip="{{.Commit.Author.When | formatDate}}">{{.Commit.Author.When | relativeTime}}</time>
+    </p>
   </hgroup>
   {{$body := .Commit.Message | commitBody}}
   {{if $body}}

pkg/web/templates/commits.html 🔗

@@ -24,7 +24,15 @@
     </details>
     {{end}}
      <p>
-       <strong>{{.Author.Name}}</strong> created
+       {{ $names := . | attributionNames }}
+       {{ $nlen := len $names }}
+       {{ range $i, $n := $names }}
+         {{ if gt $i 0 }}
+           {{ if eq $nlen 2 }} and {{ else if eq $i (sub $nlen 1) }}, and {{ else }}, {{ end }}
+         {{ end }}
+         <strong>{{$n}}</strong>
+       {{ end }}
+       created
        <time datetime="{{.Author.When | rfc3339}}" data-tooltip="{{.Author.When | formatDate}}">{{.Author.When | relativeTime}}</time>
      </p>
   </article>

pkg/web/util.go 🔗

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"strings"
 )
 
 func renderStatus(code int) http.HandlerFunc {
@@ -12,3 +13,100 @@ func renderStatus(code int) http.HandlerFunc {
 		io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) //nolint: errcheck
 	}
 }
+
+// ParseTrailers extracts Git commit message trailers from the given message.
+// Trailers are key-value pairs at the end of the commit message in the format
+// "Key: value". This function returns a map where keys are normalized to lowercase
+// and values are arrays to support multiple trailers with the same key.
+func ParseTrailers(message string) map[string][]string {
+	trailers := make(map[string][]string)
+
+	lines := strings.Split(message, "\n")
+
+	// Trim trailing blank lines so we don't select an empty "block" past the end
+	for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
+		lines = lines[:len(lines)-1]
+	}
+	if len(lines) == 0 {
+		return trailers
+	}
+
+	// Find the start of the last paragraph (after the last blank line)
+	start := 0
+	for i := len(lines) - 1; i >= 0; i-- {
+		if strings.TrimSpace(lines[i]) == "" {
+			start = i + 1
+			break
+		}
+	}
+
+	// Parse trailers from the trailer block, skipping non-trailer lines
+	for i := start; i < len(lines); i++ {
+		line := strings.TrimSpace(lines[i])
+		if line == "" {
+			continue
+		}
+		if key, value, ok := parseTrailerLine(line); ok {
+			key = strings.ToLower(key)
+			trailers[key] = append(trailers[key], value)
+		}
+	}
+
+	return trailers
+}
+
+// parseTrailerLine extracts the key and value from a trailer line.
+func parseTrailerLine(line string) (key, value string, ok bool) {
+	// Split on first : or #
+	sepIdx := -1
+	for i, ch := range line {
+		if ch == ':' || ch == '#' {
+			sepIdx = i
+			break
+		}
+	}
+
+	if sepIdx == -1 {
+		return "", "", false
+	}
+
+	key = strings.TrimSpace(line[:sepIdx])
+	value = strings.TrimSpace(line[sepIdx+1:])
+
+	if key == "" || value == "" {
+		return "", "", false
+	}
+
+	return key, value, true
+}
+
+// GetCoAuthors extracts co-author names from commit message trailers.
+// It looks for "Co-authored-by" or "Co-Authored-By" trailers and extracts
+// the name portion (without email address).
+func GetCoAuthors(message string) []string {
+	trailers := ParseTrailers(message)
+	coAuthors := []string{}
+
+	// Look for co-authored-by trailer (case-insensitive)
+	if authors, ok := trailers["co-authored-by"]; ok {
+		for _, author := range authors {
+			// Extract name from "Name <email>" format
+			name := extractName(author)
+			if name != "" {
+				coAuthors = append(coAuthors, name)
+			}
+		}
+	}
+
+	return coAuthors
+}
+
+// extractName extracts the name portion from "Name <email>" format.
+// If no email is present, returns the entire string trimmed.
+func extractName(author string) string {
+	// Look for "Name <email>" pattern
+	if idx := strings.Index(author, "<"); idx != -1 {
+		return strings.TrimSpace(author[:idx])
+	}
+	return strings.TrimSpace(author)
+}

pkg/web/webui.go 🔗

@@ -15,6 +15,7 @@ import (
 	"time"
 
 	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/pkg/proto"
 	"github.com/dustin/go-humanize"
 	"github.com/gorilla/mux"
@@ -65,6 +66,9 @@ type PaginationData struct {
 // parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
 // and humanizeSize (format file size in human-readable format).
 var templateFuncs = template.FuncMap{
+	"sub": func(a, b int) int {
+		return a - b
+	},
 	"splitPath": func(path string) []string {
 		if path == "." {
 			return []string{}
@@ -171,6 +175,59 @@ var templateFuncs = template.FuncMap{
 			return ""
 		}
 	},
+	"formatAttribution": func(commit interface{}) string {
+		var authorName, message string
+
+		switch v := commit.(type) {
+		case *git.Commit:
+			if v == nil {
+				return ""
+			}
+			authorName = v.Author.Name
+			message = v.Message
+		default:
+			return ""
+		}
+
+		coAuthors := GetCoAuthors(message)
+
+		if len(coAuthors) == 0 {
+			return authorName
+		}
+
+		if len(coAuthors) == 1 {
+			return authorName + " and " + coAuthors[0]
+		}
+
+		// Multiple co-authors: "Author, Co1, Co2, and Co3"
+		result := authorName
+		for i, coAuthor := range coAuthors {
+			if i == len(coAuthors)-1 {
+				result += ", and " + coAuthor
+			} else {
+				result += ", " + coAuthor
+			}
+		}
+		return result
+	},
+	"attributionNames": func(commit interface{}) []string {
+		switch v := commit.(type) {
+		case *git.Commit:
+			if v == nil {
+				return nil
+			}
+			names := []string{v.Author.Name}
+			for _, n := range GetCoAuthors(v.Message) {
+				n = strings.TrimSpace(n)
+				if n != "" {
+					names = append(names, n)
+				}
+			}
+			return names
+		default:
+			return nil
+		}
+	},
 }
 
 // renderHTML renders an HTML template with the given data.