dmitri.shuralyov.com/service/change/...

gerritapi: parse inline comments out of new patchset messages

In Gerrit, it's possible to publish inline comments on patchset push.
Such messages look like this:

	Uploaded patch set 2.

	(3 comments)

Parse and display such comments.
dmitshur committed 3 months ago commit 3bb9b99b3801c03b70ee3c606264f20b2842eafa
gerritapi/gerritapi.go
@@ -5,10 +5,11 @@ import (
 	"context"
 	"fmt"
 	"net/http"
 	"os"
 	"sort"
+	"strconv"
 	"strings"
 	"unicode"
 
 	"dmitri.shuralyov.com/service/change"
 	"github.com/andygrunwald/go-gerrit"
@@ -285,10 +286,41 @@ func (s service) ListTimeline(ctx context.Context, repo string, id uint64, opt *
 					Payload: change.MergedEvent{
 						CommitID: message.Message[46:86], // TODO: Make safer.
 						RefName:  chg.Branch,
 					},
 				})
+			case "gerrit:newPatchSet":
+				// Parse a new patchset message, check if it has comments.
+				body, err := parsePSMessage(message.Message, message.RevisionNumber)
+				if err != nil {
+					return nil, err
+				}
+				if body == "" {
+					// No body means no comments.
+					break
+				}
+				var cs []change.InlineComment
+				for file, comments := range *comments {
+					for _, c := range comments {
+						if c.Updated.Equal(message.Date.Time) {
+							cs = append(cs, change.InlineComment{
+								File: file,
+								Line: c.Line,
+								Body: c.Message,
+							})
+						}
+					}
+				}
+				timeline = append(timeline, change.Review{
+					ID:        fmt.Sprint(idx), // TODO: message.ID is not uint64; e.g., "bfba753d015916303152305cee7152ea7a112fe0".
+					User:      s.gerritUser(message.Author),
+					CreatedAt: message.Date.Time,
+					State:     change.Commented,
+					Body:      body,
+					Editable:  false,
+					Comments:  cs,
+				})
 			}
 			continue
 		}
 		labels, body, ok := parseMessage(message.Message)
 		if !ok {
@@ -364,10 +396,57 @@ func parseMessage(m string) (labels string, body string, ok bool) {
 	}
 
 	return labels, body, true
 }
 
+// parsePSMessage parses an autogenerated:gerrit:newPatchSet
+// message and returns its body, if any.
+func parsePSMessage(m string, revisionNumber int) (body string, _ error) {
+	// "Uploaded patch set ".
+	if !strings.HasPrefix(m, "Uploaded patch set ") {
+		return "", fmt.Errorf("unexpected format")
+	}
+	m = m[len("Uploaded patch set "):]
+
+	// Revision number, e.g., "123".
+	i := matchNumber(m, revisionNumber)
+	if i == -1 {
+		return "", fmt.Errorf("unexpected format")
+	}
+	m = m[i:]
+
+	// ".".
+	if len(m) < 1 || m[0] != '.' {
+		return "", fmt.Errorf("unexpected format")
+	}
+	m = m[1:]
+
+	if m == "" {
+		// No body.
+		return "", nil
+	}
+
+	// "\n\n".
+	if !strings.HasPrefix(m, "\n\n") {
+		return "", fmt.Errorf("unexpected format")
+	}
+	m = m[len("\n\n"):]
+
+	// The remainer is the body.
+	return m, nil
+}
+
+// matchNumber returns the index after number in s,
+// or -1 if number is not immediately present in s.
+func matchNumber(s string, number int) int {
+	a := strconv.Itoa(number)
+	if !strings.HasPrefix(s, a) {
+		return -1
+	}
+	return len(a)
+}
+
 func reviewState(labels string) change.ReviewState {
 	for _, label := range strings.Split(labels, " ") {
 		switch label {
 		case "Code-Review+2":
 			return change.Approved
gerritapi/gerritapi_test.go
@@ -42,5 +42,43 @@ func TestParseMessage(t *testing.T) {
 		if gotBody != tc.wantBody {
 			t.Errorf("%d: got body: %q, want: %q", i, gotBody, tc.wantBody)
 		}
 	}
 }
+
+func TestParsePSMessage(t *testing.T) {
+	tests := []struct {
+		inMessage        string
+		inRevisionNumber int
+		wantBody         string
+		wantError        bool
+	}{
+		{
+			inMessage:        "Uploaded patch set 1.",
+			inRevisionNumber: 1,
+			wantBody:         "",
+		},
+		{
+			inMessage:        "Uploaded patch set 2.\n\n(3 comments)",
+			inRevisionNumber: 2,
+			wantBody:         "(3 comments)",
+		},
+		{
+			inMessage:        "something unexpected",
+			inRevisionNumber: 3,
+			wantError:        true,
+		},
+	}
+	for i, tc := range tests {
+		body, err := parsePSMessage(tc.inMessage, tc.inRevisionNumber)
+		if got, want := err != nil, tc.wantError; got != want {
+			t.Errorf("%d: got error: %v, want: %v", i, got, want)
+			continue
+		}
+		if tc.wantError {
+			continue
+		}
+		if got, want := body, tc.wantBody; got != want {
+			t.Errorf("%d: got body: %q, want: %q", i, got, want)
+		}
+	}
+}