From ae7f5236bfd8fd49ec2576dfd717d2765dc991d0 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 18 Oct 2025 15:08:54 -0600 Subject: [PATCH] feat(web): display co-authors in commit views 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 --- 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(-) diff --git a/pkg/web/templates/commit.html b/pkg/web/templates/commit.html index 638bc5aca7e83d821ffe159bee52b0350dcf403d..4bf5785d7b729f57d5ada2dba4278e7e091e4326 100644 --- a/pkg/web/templates/commit.html +++ b/pkg/web/templates/commit.html @@ -9,7 +9,17 @@

{{.Commit.Message | commitSubject}}

-

{{.Commit.Author.Name}} created

+

+ {{ $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 }} + {{$n}} + {{ end }} + created +

{{$body := .Commit.Message | commitBody}} {{if $body}} diff --git a/pkg/web/templates/commits.html b/pkg/web/templates/commits.html index c5679b20bbf4b443e710acf8f6e9cee2db190265..d0998b47d12756d4dfcae977bc009e44113b3f39 100644 --- a/pkg/web/templates/commits.html +++ b/pkg/web/templates/commits.html @@ -24,7 +24,15 @@ {{end}}

- {{.Author.Name}} 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 }} + {{$n}} + {{ end }} + created

diff --git a/pkg/web/util.go b/pkg/web/util.go index cc2429d633775e7c223c5c11e328c28d0d4647fa..a36794571fcdcfa7d759c90bd38bc2b36c98cf27 100644 --- a/pkg/web/util.go +++ b/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 " format + name := extractName(author) + if name != "" { + coAuthors = append(coAuthors, name) + } + } + } + + return coAuthors +} + +// extractName extracts the name portion from "Name " format. +// If no email is present, returns the entire string trimmed. +func extractName(author string) string { + // Look for "Name " pattern + if idx := strings.Index(author, "<"); idx != -1 { + return strings.TrimSpace(author[:idx]) + } + return strings.TrimSpace(author) +} diff --git a/pkg/web/webui.go b/pkg/web/webui.go index 61837bc973fb6489912cd8ca3e1b535139f44c4a..51eb8ea96cc28071998d37f9ff95be50c93b6644 100644 --- a/pkg/web/webui.go +++ b/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.