Add pull request capability
Change-Id: Ib54054cc9b32930764cc2110203742c3948f9ea3
diff --git a/server/git/gerrit.go b/server/git/gerrit.go
new file mode 100644
index 0000000..6ce60e9
--- /dev/null
+++ b/server/git/gerrit.go
@@ -0,0 +1,454 @@
+package git
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GerritConfig holds configuration for Gerrit operations
+type GerritConfig struct {
+ Username string
+ Password string // Can be HTTP password or API token
+ BaseURL string
+ HTTPClient *http.Client
+}
+
+// GerritPullRequestProvider implements PullRequestProvider for Gerrit
+type GerritPullRequestProvider struct {
+ config GerritConfig
+ project string
+}
+
+// NewGerritPullRequestProvider creates a new Gerrit pull request provider
+func NewGerritPullRequestProvider(project string, config GerritConfig) PullRequestProvider {
+ if config.HTTPClient == nil {
+ config.HTTPClient = &http.Client{Timeout: 30 * time.Second}
+ }
+
+ return &GerritPullRequestProvider{
+ config: config,
+ project: project,
+ }
+}
+
+// Gerrit API response types
+type gerritChange struct {
+ ID string `json:"id"`
+ Number int `json:"_number"`
+ Subject string `json:"subject"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Owner gerritAccount `json:"owner"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ Branch string `json:"branch"`
+ Topic string `json:"topic"`
+ Labels map[string]gerritLabelInfo `json:"labels"`
+ Reviewers map[string][]gerritAccount `json:"reviewers"`
+ CurrentRevision string `json:"current_revision"`
+ Revisions map[string]gerritRevision `json:"revisions"`
+ Messages []gerritMessage `json:"messages"`
+}
+
+type gerritAccount struct {
+ AccountID int `json:"_account_id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+}
+
+type gerritLabelInfo struct {
+ All []gerritApproval `json:"all"`
+}
+
+type gerritApproval struct {
+ AccountID int `json:"_account_id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Value int `json:"value"`
+}
+
+type gerritRevision struct {
+ Number int `json:"_number"`
+ Ref string `json:"ref"`
+ Fetch map[string]gerritFetchInfo `json:"fetch"`
+ Commit gerritCommit `json:"commit"`
+ Files map[string]gerritFileInfo `json:"files"`
+}
+
+type gerritFetchInfo struct {
+ URL string `json:"url"`
+ Ref string `json:"ref"`
+}
+
+type gerritCommit struct {
+ Subject string `json:"subject"`
+ Message string `json:"message"`
+ Author gerritPerson `json:"author"`
+}
+
+type gerritPerson struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Date string `json:"date"`
+}
+
+type gerritFileInfo struct {
+ Status string `json:"status"`
+ LinesInserted int `json:"lines_inserted"`
+ LinesDeleted int `json:"lines_deleted"`
+}
+
+type gerritMessage struct {
+ ID string `json:"id"`
+ Author gerritAccount `json:"author"`
+ Message string `json:"message"`
+ Date time.Time `json:"date"`
+ RevisionNumber int `json:"_revision_number"`
+}
+
+type gerritCreateChangeRequest struct {
+ Project string `json:"project"`
+ Subject string `json:"subject"`
+ Description string `json:"description"`
+ Branch string `json:"branch"`
+ Topic string `json:"topic,omitempty"`
+ Base string `json:"base,omitempty"`
+}
+
+type gerritUpdateChangeRequest struct {
+ Subject string `json:"subject,omitempty"`
+ Description string `json:"description,omitempty"`
+ Topic string `json:"topic,omitempty"`
+ Status string `json:"status,omitempty"`
+}
+
+type gerritReviewRequest struct {
+ Message string `json:"message,omitempty"`
+ Labels map[string]int `json:"labels,omitempty"`
+ Reviewers []string `json:"reviewers,omitempty"`
+ Comments map[string][]gerritComment `json:"comments,omitempty"`
+}
+
+type gerritComment struct {
+ Line int `json:"line"`
+ Message string `json:"message"`
+}
+
+// CreatePullRequest creates a new change (pull request) on Gerrit
+func (g *GerritPullRequestProvider) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error) {
+ reqBody := gerritCreateChangeRequest{
+ Project: g.project,
+ Subject: options.Title,
+ Description: options.Description,
+ Branch: options.BaseBranch,
+ Topic: options.HeadBranch, // Use head branch as topic
+ }
+
+ jsonBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ // Gerrit returns the change ID in the response body
+ var changeID string
+ if err := json.NewDecoder(resp.Body).Decode(&changeID); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Remove the ")]}'" prefix that Gerrit adds to responses
+ changeID = strings.TrimPrefix(changeID, ")]}'")
+
+ // Fetch the created change
+ return g.GetPullRequest(ctx, changeID)
+}
+
+// GetPullRequest retrieves a change by ID
+func (g *GerritPullRequestProvider) GetPullRequest(ctx context.Context, id string) (*PullRequest, error) {
+ url := fmt.Sprintf("%s/a/changes/%s?o=DETAILED_ACCOUNTS&o=DETAILED_LABELS&o=MESSAGES&o=CURRENT_REVISION&o=CURRENT_COMMIT", g.config.BaseURL, id)
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ // Read and remove Gerrit's ")]}'" prefix
+ body := make([]byte, 4)
+ if _, err := resp.Body.Read(body); err != nil {
+ return nil, fmt.Errorf("failed to read response prefix: %w", err)
+ }
+ if string(body) != ")]}'" {
+ return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
+ }
+
+ var gerritChange gerritChange
+ if err := json.NewDecoder(resp.Body).Decode(&gerritChange); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return g.convertGerritChange(gerritChange), nil
+}
+
+// ListPullRequests lists changes
+func (g *GerritPullRequestProvider) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error) {
+ url := fmt.Sprintf("%s/a/changes/", g.config.BaseURL)
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add query parameters
+ q := req.URL.Query()
+ q.Add("q", fmt.Sprintf("project:%s", g.project))
+
+ if options.State != "" {
+ switch options.State {
+ case "open":
+ q.Add("q", "status:open")
+ case "closed":
+ q.Add("q", "status:closed")
+ }
+ }
+ if options.Author != "" {
+ q.Add("q", fmt.Sprintf("owner:%s", options.Author))
+ }
+ if options.Assignee != "" {
+ q.Add("q", fmt.Sprintf("reviewer:%s", options.Assignee))
+ }
+ if options.BaseBranch != "" {
+ q.Add("q", fmt.Sprintf("branch:%s", options.BaseBranch))
+ }
+ if options.Limit > 0 {
+ q.Add("n", strconv.Itoa(options.Limit))
+ }
+ req.URL.RawQuery = q.Encode()
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ // Read and remove Gerrit's ")]}'" prefix
+ body := make([]byte, 4)
+ if _, err := resp.Body.Read(body); err != nil {
+ return nil, fmt.Errorf("failed to read response prefix: %w", err)
+ }
+ if string(body) != ")]}'" {
+ return nil, fmt.Errorf("unexpected response prefix: %s", string(body))
+ }
+
+ var gerritChanges []gerritChange
+ if err := json.NewDecoder(resp.Body).Decode(&gerritChanges); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ prs := make([]PullRequest, len(gerritChanges))
+ for i, change := range gerritChanges {
+ prs[i] = *g.convertGerritChange(change)
+ }
+
+ return prs, nil
+}
+
+// UpdatePullRequest updates a change
+func (g *GerritPullRequestProvider) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error) {
+ reqBody := gerritUpdateChangeRequest{
+ Subject: options.Title,
+ Description: options.Description,
+ }
+
+ jsonBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/a/changes/%s", g.config.BaseURL, id)
+ req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ // Fetch the updated change
+ return g.GetPullRequest(ctx, id)
+}
+
+// ClosePullRequest closes a change
+func (g *GerritPullRequestProvider) ClosePullRequest(ctx context.Context, id string) error {
+ reqBody := gerritUpdateChangeRequest{
+ Status: "ABANDONED",
+ }
+
+ jsonBody, err := json.Marshal(reqBody)
+ if err != nil {
+ return fmt.Errorf("failed to marshal request body: %w", err)
+ }
+
+ url := fmt.Sprintf("%s/a/changes/%s/abandon", g.config.BaseURL, id)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ return nil
+}
+
+// MergePullRequest merges a change
+func (g *GerritPullRequestProvider) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error {
+ url := fmt.Sprintf("%s/a/changes/%s/submit", g.config.BaseURL, id)
+ req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.SetBasicAuth(g.config.Username, g.config.Password)
+
+ resp, err := g.config.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to make request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("Gerrit API error: %d", resp.StatusCode)
+ }
+
+ return nil
+}
+
+// convertGerritChange converts a Gerrit change to our PullRequest type
+func (g *GerritPullRequestProvider) convertGerritChange(change gerritChange) *PullRequest {
+ // Extract labels
+ var labels []string
+ for labelName := range change.Labels {
+ labels = append(labels, labelName)
+ }
+
+ // Extract reviewers
+ var reviewers []Author
+ if reviewersMap, exists := change.Reviewers["REVIEWER"]; exists {
+ for _, reviewer := range reviewersMap {
+ reviewers = append(reviewers, Author{
+ Name: reviewer.Name,
+ Email: reviewer.Email,
+ })
+ }
+ }
+
+ // Extract comments from messages
+ var comments []PullRequestComment
+ for _, message := range change.Messages {
+ if message.Message != "" {
+ comments = append(comments, PullRequestComment{
+ ID: message.ID,
+ Author: Author{
+ Name: message.Author.Name,
+ Email: message.Author.Email,
+ },
+ Content: message.Message,
+ CreatedAt: message.Date,
+ UpdatedAt: message.Date,
+ })
+ }
+ }
+
+ // Determine state
+ state := "open"
+ switch change.Status {
+ case "MERGED":
+ state = "merged"
+ case "ABANDONED":
+ state = "closed"
+ }
+
+ return &PullRequest{
+ ID: change.ID,
+ Number: change.Number,
+ Title: change.Subject,
+ Description: change.Description,
+ State: state,
+ Author: Author{
+ Name: change.Owner.Name,
+ Email: change.Owner.Email,
+ },
+ CreatedAt: change.Created,
+ UpdatedAt: change.Updated,
+ BaseBranch: change.Branch,
+ HeadBranch: change.Topic, // Use topic as head branch
+ BaseRepo: g.project,
+ HeadRepo: g.project, // Gerrit changes are within the same project
+ Labels: labels,
+ Assignees: []Author{}, // Gerrit doesn't have assignees in the same way
+ Reviewers: reviewers,
+ Commits: []Commit{}, // Would need additional API call to populate
+ Comments: comments,
+ }
+}