1// Copyright 2019 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// Package db is responsible for getting information about CLs and PRs in Gerrit
16// and GitHub respectively.
17package db
18
19import (
20	"context"
21	"fmt"
22	"log"
23	"net/http"
24	"strconv"
25	"strings"
26	"sync"
27	"time"
28
29	"github.com/andygrunwald/go-gerrit"
30	"github.com/google/go-github/github"
31)
32
33// RegenAttempt represents either a genproto regen PR or a gocloud gapic regen
34// CL.
35type RegenAttempt interface {
36	Author() string
37	Title() string
38	URL() string
39	Created() time.Time
40	Open() bool
41}
42
43// ByCreated allows RegenAttempt to be sorted by Created field.
44type ByCreated []RegenAttempt
45
46func (a ByCreated) Len() int           { return len(a) }
47func (a ByCreated) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
48func (a ByCreated) Less(i, j int) bool { return a[i].Created().After(a[j].Created()) }
49
50// regenAttempt represents either a genproto regen PR or a gocloud gapic regen
51// CL.
52type regenAttempt struct {
53	author  string
54	title   string
55	url     string
56	created time.Time
57	open    bool
58}
59
60func (ra *regenAttempt) Author() string     { return ra.author }
61func (ra *regenAttempt) Title() string      { return ra.title }
62func (ra *regenAttempt) URL() string        { return ra.url }
63func (ra *regenAttempt) Created() time.Time { return ra.created }
64func (ra *regenAttempt) Open() bool         { return ra.open }
65
66// GerritRegenAttempt is a gerrit regen attempt (a CL).
67type GerritRegenAttempt struct {
68	regenAttempt
69	ChangeID string
70}
71
72// GenprotoRegenAttempt is a genproto regen attempt (a PR).
73type GenprotoRegenAttempt struct {
74	regenAttempt
75}
76
77// Db can communicate with GitHub and Gerrit to get PRs / CLs.
78type Db struct {
79	gerritClient *gerrit.Client
80	githubClient *github.Client
81
82	cacheMu sync.Mutex
83	// For some reason, the Changes API only returns AccountID. So we cache the
84	// accountID->name to improve performance / reduce adtl calls.
85	cachedGerritAccounts map[int]string // accountid -> name
86}
87
88// New returns a new Db.
89func New(ctx context.Context, githubClient *github.Client, gerritClient *gerrit.Client) *Db {
90	db := &Db{
91		githubClient: githubClient,
92		gerritClient: gerritClient,
93
94		cacheMu:              sync.Mutex{},
95		cachedGerritAccounts: map[int]string{},
96	}
97
98	return db
99}
100
101// GetPRs fetches regen PRs from genproto.
102func (db *Db) GetPRs(ctx context.Context) ([]RegenAttempt, error) {
103	log.Println("getting genproto changes")
104	genprotoPRs := []RegenAttempt{}
105
106	// We don't bother paginating, because it hurts our requests quota and makes
107	// the page slower without a lot of value.
108	opt := &github.PullRequestListOptions{
109		ListOptions: github.ListOptions{PerPage: 50},
110		State:       "all",
111	}
112	prs, _, err := db.githubClient.PullRequests.List(ctx, "googleapis", "go-genproto", opt)
113	if err != nil {
114		return nil, err
115	}
116	for _, pr := range prs {
117		if !strings.Contains(pr.GetTitle(), "regen") {
118			continue
119		}
120		genprotoPRs = append(genprotoPRs, &GenprotoRegenAttempt{
121			regenAttempt: regenAttempt{
122				author:  pr.GetUser().GetLogin(),
123				title:   pr.GetTitle(),
124				url:     pr.GetHTMLURL(),
125				created: pr.GetCreatedAt(),
126				open:    pr.GetState() == "open",
127			},
128		})
129	}
130	return genprotoPRs, nil
131}
132
133// GetCLs fetches regen CLs from Gerrit.
134func (db *Db) GetCLs(ctx context.Context) ([]RegenAttempt, error) {
135	log.Println("getting gocloud changes")
136	gocloudCLs := []RegenAttempt{}
137
138	changes, _, err := db.gerritClient.Changes.QueryChanges(&gerrit.QueryChangeOptions{
139		QueryOptions: gerrit.QueryOptions{Query: []string{"project:gocloud"}, Limit: 200},
140	})
141	if err != nil {
142		return nil, err
143	}
144
145	for _, c := range *changes {
146		if !strings.Contains(c.Subject, "regen") {
147			continue
148		}
149
150		// For some reason, the Changes API only returns AccountID. So now we
151		// have to go get the name.
152		db.cacheMu.Lock()
153		if _, ok := db.cachedGerritAccounts[c.Owner.AccountID]; !ok {
154			log.Println("looking up user", c.Owner.AccountID)
155			ai, resp, err := db.gerritClient.Accounts.GetAccount(strconv.Itoa(c.Owner.AccountID))
156			if err != nil {
157				if resp.StatusCode == http.StatusNotFound {
158					db.cachedGerritAccounts[c.Owner.AccountID] = fmt.Sprintf("unknown user account ID: %d\n", c.Owner.AccountID)
159				} else {
160					db.cacheMu.Unlock()
161					return nil, err
162				}
163			} else {
164				db.cachedGerritAccounts[c.Owner.AccountID] = ai.Email
165			}
166		}
167
168		gocloudCLs = append(gocloudCLs, &GerritRegenAttempt{
169			regenAttempt: regenAttempt{
170				author:  db.cachedGerritAccounts[c.Owner.AccountID],
171				title:   c.Subject,
172				url:     fmt.Sprintf("https://code-review.googlesource.com/q/%s", c.ChangeID),
173				created: c.Created.Time,
174				open:    c.Status == "NEW",
175			},
176			ChangeID: c.ChangeID,
177		})
178		db.cacheMu.Unlock()
179	}
180
181	return gocloudCLs, nil
182}
183
184// FirstOpen returns the first open regen attempt.
185func FirstOpen(ras []RegenAttempt) (RegenAttempt, bool) {
186	for _, ra := range ras {
187		if ra.Open() {
188			return ra, true
189		}
190	}
191	return nil, false
192}
193