[bugs-central] Github implementation of the BugFramework interface

Bug: skia:10783
Change-Id: I3890691942bbf752c8b2b81c04c42b2324aad958
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/328988
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/bugs-central/go/bugs/github/github.go b/bugs-central/go/bugs/github/github.go
new file mode 100644
index 0000000..e1e99ca
--- /dev/null
+++ b/bugs-central/go/bugs/github/github.go
@@ -0,0 +1,222 @@
+package github
+
+// Accesses github issues API.
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"strconv"
+	"strings"
+	"time"
+
+	"go.skia.org/infra/bugs-central/go/bugs"
+	"go.skia.org/infra/bugs-central/go/db"
+	"go.skia.org/infra/bugs-central/go/types"
+	github_api "go.skia.org/infra/go/github"
+	"go.skia.org/infra/go/httputils"
+	"go.skia.org/infra/go/skerr"
+	"go.skia.org/infra/go/sklog"
+	"go.skia.org/infra/go/util"
+	"golang.org/x/oauth2"
+)
+
+const (
+	// Not clear what the maximum allowable results are for github API.
+	maxGithubResults = 1000
+
+	githubSource types.IssueSource = "Github"
+)
+
+type githubPriorityData map[string]types.StandardizedPriority
+
+var (
+	// Maps the priority label names into the standardized priorities.
+	githubProjectToPriorityData map[string]githubPriorityData = map[string]githubPriorityData{
+		"flutter/flutter": {
+			// https://github.com/flutter/flutter/labels/P0
+			"P0": types.PriorityP0,
+			// https://github.com/flutter/flutter/labels/P1
+			"P1": types.PriorityP1,
+			// https://github.com/flutter/flutter/labels/P2
+			"P2": types.PriorityP2,
+			// https://github.com/flutter/flutter/labels/P3
+			"P3": types.PriorityP3,
+			// https://github.com/flutter/flutter/labels/P4
+			"P4": types.PriorityP4,
+			// https://github.com/flutter/flutter/labels/P5
+			"P5": types.PriorityP5,
+			// https://github.com/flutter/flutter/labels/P6
+			"P6": types.PriorityP6,
+		},
+	}
+)
+
+// githubFramework implements bugs.BugFramework for github repos.
+type githubFramework struct {
+	githubClient *github_api.GitHub
+	projectName  string
+	openIssues   *bugs.OpenIssues
+	queryConfig  *GithubQueryConfig
+}
+
+// GithubQueryConfig is the config that will be used when querying github API.
+type GithubQueryConfig struct {
+	// Slice of labels to look for in Github issues.
+	Labels []string
+	// Slice of labels to exclude.
+	ExcludeLabels []string
+	// Return only open issues.
+	Open bool
+	// If an issues has no priority label then do not include it in results.
+	PriorityRequired bool
+	// Which client's issues we are looking for.
+	Client types.RecognizedClient
+}
+
+// New returns an instance of the github implementation of bugs.BugFramework.
+func New(ctx context.Context, repoOwner, repoName, credPath string, openIssues *bugs.OpenIssues, queryConfig *GithubQueryConfig) (bugs.BugFramework, error) {
+	gBody, err := ioutil.ReadFile(credPath)
+	if err != nil {
+		return nil, skerr.Wrapf(err, "could not find githubToken in %s", credPath)
+	}
+	gToken := strings.TrimSpace(string(gBody))
+	githubTS := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: gToken})
+	githubHttpClient := httputils.DefaultClientConfig().With2xxOnly().WithTokenSource(githubTS).Client()
+	githubClient, err := github_api.NewGitHub(ctx, repoOwner, repoName, githubHttpClient)
+	if err != nil {
+		return nil, skerr.Wrapf(err, "could not instantiate github client")
+	}
+
+	return &githubFramework{
+		githubClient: githubClient,
+		projectName:  fmt.Sprintf("%s/%s", repoOwner, repoName),
+		openIssues:   openIssues,
+		queryConfig:  queryConfig,
+	}, nil
+}
+
+// See documentation for bugs.Search interface.
+func (gh *githubFramework) Search(ctx context.Context) ([]*types.Issue, *types.IssueCountsData, error) {
+	githubIssues, err := gh.githubClient.GetIssues(gh.queryConfig.Open, gh.queryConfig.Labels, maxGithubResults)
+	if err != nil {
+		return nil, nil, skerr.Wrapf(err, "could not get github issues with %s labels", gh.queryConfig.Labels)
+	}
+
+	// Convert github issues into bug_framework's generic issues
+	issues := []*types.Issue{}
+	countsData := &types.IssueCountsData{}
+	priorityData, ok := githubProjectToPriorityData[gh.projectName]
+	if !ok {
+		sklog.Errorf("Could not find GithubPriorityData for project %s", gh.projectName)
+	}
+	for _, gi := range githubIssues {
+		owner := ""
+		if gi.GetAssignee() != nil {
+			owner = gi.GetAssignee().GetEmail()
+		}
+		id := strconv.Itoa(gi.GetNumber())
+
+		if gh.queryConfig.ExcludeLabels != nil {
+			// Github API does not support a way to exclude labels right now. Going to do it
+			// by looping and manually excluding issues.
+			foundLabelToExclude := false
+			for _, l := range gi.Labels {
+				if util.In(l.GetName(), gh.queryConfig.ExcludeLabels) {
+					foundLabelToExclude = true
+					break
+				}
+			}
+			if foundLabelToExclude {
+				continue
+			}
+		}
+
+		// Find priority.
+		priority := types.StandardizedPriority("")
+		if priorityData != nil {
+			// Go through labels for this issue to see if any of them are priority labels.
+			for _, l := range gi.Labels {
+				if p, ok := priorityData[*l.Name]; ok {
+					priority = p
+					// What happens if there are multiple priority labels attached? Use the
+					// first one we encounter because that one *should* be the highest priority.
+					break
+				}
+			}
+		}
+		if gh.queryConfig.PriorityRequired && priority == "" {
+			continue
+		}
+
+		// Populate counts data.
+		if owner == "" {
+			countsData.UnassignedCount++
+		}
+		countsData.OpenCount++
+		countsData.IncPriority(priority)
+		countsData.CalculateSLOViolations(time.Now(), gi.GetCreatedAt(), gi.GetUpdatedAt(), priority)
+
+		issues = append(issues, &types.Issue{
+			Id:       id,
+			State:    gi.GetState(),
+			Priority: priority,
+			Owner:    owner,
+			Link:     gh.GetIssueLink("", id),
+
+			CreatedTime:  gi.GetCreatedAt(),
+			ModifiedTime: gi.GetUpdatedAt(),
+
+			Title:   gi.GetTitle(),
+			Summary: gi.GetBody(),
+		})
+	}
+
+	return issues, countsData, nil
+}
+
+// See documentation for bugs.SearchClientAndPersist interface.
+func (gh *githubFramework) SearchClientAndPersist(ctx context.Context, dbClient *db.FirestoreDB, runId string) error {
+	issues, countsData, err := gh.Search(ctx)
+	if err != nil {
+		return skerr.Wrapf(err, "error when searching github")
+	}
+	sklog.Infof("%s Github issues %+v", gh.queryConfig.Client, countsData)
+
+	// Construct the query description from labels and exclude labels.
+	labelsInQuery := []string{}
+	for _, l := range gh.queryConfig.Labels {
+		labelsInQuery = append(labelsInQuery, fmt.Sprintf("label:\"%s\"", l))
+	}
+	queryDesc := strings.Join(labelsInQuery, "+")
+	if gh.queryConfig.ExcludeLabels != nil {
+		excludeLabels := []string{}
+		for _, e := range gh.queryConfig.ExcludeLabels {
+			excludeLabels = append(excludeLabels, fmt.Sprintf("-label:\"%s\"", e))
+		}
+		queryDesc = fmt.Sprintf("%s+%s", queryDesc, strings.Join(excludeLabels, "+"))
+	}
+	queryLink := fmt.Sprintf("https://github.com/%s/issues?q=is:issue+is:open+%s", gh.projectName, queryDesc)
+	if gh.queryConfig.PriorityRequired {
+		// The github query link and API does not support filtering by priority, it was manually done.
+		// Show that priority was required in the query description.
+		queryDesc += " priority-required"
+	}
+	countsData.QueryLink = queryLink
+	// Github does not have an untriaged query link yet so use the open issues link instead.
+	countsData.UntriagedQueryLink = queryLink
+	client := gh.queryConfig.Client
+
+	// Put in DB.
+	if err := dbClient.PutInDB(ctx, client, githubSource, queryDesc, runId, countsData); err != nil {
+		return skerr.Wrapf(err, "error putting github results in DB")
+	}
+	// Put in memory.
+	gh.openIssues.PutOpenIssues(client, githubSource, queryDesc, issues)
+	return nil
+}
+
+// See documentation for bugs.GetIssueLink interface.
+func (gh *githubFramework) GetIssueLink(_, id string) string {
+	return gh.githubClient.GetIssueUrlBase() + id
+}
diff --git a/bugs-central/go/bugs/github/github_test.go b/bugs-central/go/bugs/github/github_test.go
new file mode 100644
index 0000000..39a4a0c
--- /dev/null
+++ b/bugs-central/go/bugs/github/github_test.go
@@ -0,0 +1,83 @@
+package github
+
+import (
+	"context"
+	"strconv"
+	"testing"
+
+	github_api "github.com/google/go-github/v29/github"
+	"github.com/gorilla/mux"
+	"github.com/stretchr/testify/require"
+
+	"go.skia.org/infra/bugs-central/go/bugs"
+	"go.skia.org/infra/go/github"
+	"go.skia.org/infra/go/mockhttpclient"
+	"go.skia.org/infra/go/testutils"
+	"go.skia.org/infra/go/testutils/unittest"
+)
+
+func TestGithubSearch(t *testing.T) {
+	unittest.SmallTest(t)
+	ctx := context.Background()
+
+	id1 := 11
+	id2 := 22
+	assignee := "superman@krypton.com"
+	label1Name := "abc"
+	label2Name := "xyz"
+	label3Name := "123"
+	label1 := github_api.Label{Name: &label1Name}
+	label2 := github_api.Label{Name: &label2Name}
+	label3 := github_api.Label{Name: &label3Name}
+	issue1 := github_api.Issue{
+		Number: &id1,
+		Labels: []github_api.Label{label1, label2},
+		Assignee: &github_api.User{
+			Email: &assignee,
+		},
+	}
+	issue2 := github_api.Issue{
+		Number: &id2,
+		Labels: []github_api.Label{label3},
+		Assignee: &github_api.User{
+			Email: &assignee,
+		},
+	}
+	respBody := []byte(testutils.MarshalJSON(t, []*github_api.Issue{&issue1, &issue2}))
+	r := mux.NewRouter()
+	md := mockhttpclient.MockGetDialogue(respBody)
+	r.Schemes("https").Host("api.github.com").Methods("GET").Path("/repos/kryptonians/krypton/issues").Queries("labels", "abc,xyz", "per_page", "1000", "state", "open").Handler(md)
+	httpClient := mockhttpclient.NewMuxClient(r)
+
+	githubClient, err := github.NewGitHub(ctx, "kryptonians", "krypton", httpClient)
+	require.NoError(t, err)
+
+	qc := &GithubQueryConfig{
+		Labels:           []string{label1Name, label2Name},
+		ExcludeLabels:    []string{label3Name},
+		Open:             true,
+		PriorityRequired: false,
+		Client:           "Flutter-native",
+	}
+	g := githubFramework{
+		githubClient: githubClient,
+		projectName:  "flutter/flutter",
+		openIssues:   bugs.InitOpenIssues(),
+		queryConfig:  qc,
+	}
+	issues, countData, err := g.Search(ctx)
+	require.NoError(t, err)
+
+	// There should be one matching issue and one excluded issue.
+	require.Equal(t, 1, len(issues))
+	require.Equal(t, strconv.Itoa(id1), issues[0].Id)
+	require.Equal(t, 1, countData.OpenCount)
+
+	// Change the query config to have priority required. There should be
+	// no matching issues because priority was not specified.
+	qc.PriorityRequired = true
+	issues, countData, err = g.Search(ctx)
+	require.NoError(t, err)
+	require.Equal(t, 0, len(issues))
+	require.Equal(t, 0, countData.OpenCount)
+}