diff --git a/cmd/ci-reporter/cmd/github.go b/cmd/ci-reporter/cmd/github.go index 247bea4dab0..3498f1eb7f6 100644 --- a/cmd/ci-reporter/cmd/github.go +++ b/cmd/ci-reporter/cmd/github.go @@ -19,15 +19,10 @@ package cmd import ( "context" "encoding/json" - "fmt" - "io/ioutil" - "net/http" "os" - "regexp" - "strings" - "github.com/google/go-github/v34/github" "github.com/pkg/errors" + "github.com/shurcooL/githubv4" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/oauth2" @@ -52,10 +47,11 @@ func setGithubConfig(cmd *cobra.Command, args []string) { os.Exit(1) } - ctx := context.Background() - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.GithubToken}) - tc := oauth2.NewClient(ctx, ts) - cfg.GithubClient = github.NewClient(tc) + src := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: cfg.GithubToken}, + ) + httpClient := oauth2.NewClient(context.Background(), src) + cfg.GithubClient = githubv4.NewClient(httpClient) } // GithubReporterName used to identify github reporter @@ -79,25 +75,43 @@ func (r GithubReporter) GetCIReporterHead() CIReporterInfo { // CollectReportData implementation from CIReporter func (r GithubReporter) CollectReportData(cfg *Config) ([]*CIReportRecord, error) { - githubReportData, err := GetGithubReportData(*cfg) + // set filter configuration + fieldFilter := map[FilteredFieldName][]FilteredBlacklistVal{} + if cfg.ShortReport { + fieldFilter[FilteredFieldName("Status")] = []FilteredBlacklistVal{ + FilteredBlacklistVal("RESOLVED"), + FilteredBlacklistVal("PASSING"), + } + } + if cfg.ReleaseVersion != "" { + fieldFilter[FilteredFieldName("K8s Release")] = []FilteredBlacklistVal{FilteredBlacklistVal(cfg.ReleaseVersion)} + } + // request github projectboard data + githubReportData, err := GetGithubReportData(*cfg, fieldFilter) if err != nil { return nil, errors.Wrap(err, "getting GitHub report data") } records := []*CIReportRecord{} - for columnTitle, issues := range githubReportData { - for _, issue := range issues { - records = append(records, &CIReportRecord{ - ID: fmt.Sprintf("%d", issue.ID), - Title: issue.Title, - URL: issue.URL, - Category: string(columnTitle), - Sigs: issue.Sigs, - // information not collected - Status: "", - CreatedTimestamp: "", - }) + for _, item := range githubReportData { + // set the URL to the Issue- / PR- URL if set + URL := "" + if issueURL, ok := item.Fields[fieldName(IssueURLKey)]; ok { + URL = string(issueURL) } + if prURL, ok := item.Fields[fieldName(PullRequestURLKey)]; ok { + URL = string(prURL) + } + // add a new record to the report + records = append(records, &CIReportRecord{ + Title: item.Title, + TestgridBoard: string(item.Fields[fieldName(TestgridBoardKey)]), + URL: URL, + Status: string(item.Fields[fieldName(StatusKey)]), + StatusDetails: string(item.Fields[fieldName(CiSignalMemberKey)]), + CreatedTimestamp: string(item.Fields[fieldName(CreatedAtKey)]), + UpdatedTimestamp: string(item.Fields[fieldName(UpdatedAtKey)]), + }) } return records, nil } @@ -106,144 +120,177 @@ func (r GithubReporter) CollectReportData(cfg *Config) ([]*CIReportRecord, error // Helper functions to collect github data // -// This regex is getting used to identify sig lables on github issues -var sigRegex = regexp.MustCompile(`sig/[a-zA-Z-]+`) - -var ( - newColumn = GithubProjectBoardColumn{ - ColumnTitle: "New/Not Yet Started", - ColumnID: 4212817, - } - underInvestigationColumn = GithubProjectBoardColumn{ - ColumnTitle: "In flight", - ColumnID: 4212819, - } - observingColumn = GithubProjectBoardColumn{ - ColumnTitle: "New/Not Yet Started", - ColumnID: 4212821, - } - resolvedColumn = GithubProjectBoardColumn{ - ColumnTitle: "Resolved", - ColumnID: 6798858, - } +// This can be looked up using the API, see https://docs.github.com/en/issues/trying-out-the-new-projects-experience/using-the-api-to-manage-projects#finding-the-node-id-of-an-organization-project +const ciSignalProjectBoardID = "PN_kwDOAM_34M4AAThW" + +type ciSignalProjectBoardKey string + +const ( + // custom project board keys that get extracted via graphql + IssueURLKey = ciSignalProjectBoardKey("Issue URL") + PullRequestURLKey = ciSignalProjectBoardKey("PullRequest URL") + // project board column headers + TestgridBoardKey = ciSignalProjectBoardKey("Testgrid Board") + SlackDiscussionLinkKey = ciSignalProjectBoardKey("Slack discussion link") + StatusKey = ciSignalProjectBoardKey("Status") + CiSignalMemberKey = ciSignalProjectBoardKey("CI Signal Member") + CreatedAtKey = ciSignalProjectBoardKey("Created At") + UpdatedAtKey = ciSignalProjectBoardKey("Updated At") ) -type ( - // ColumnTitle title of a github project board column - ColumnTitle string - // ColumnID ID of a github project board column - ColumnID int64 -) - -// GithubProjectBoardColumn specifies a github project board column -type GithubProjectBoardColumn struct { - ColumnTitle ColumnTitle `json:"column_title"` - ColumnID ColumnID `json:"column_id"` +// GitHubProjectBoardFieldSettings settings for a column of a github beta project board +// --> | Testgrid Board | -> { ID: XXX, Name: Testgrid Board, ... } +// This information is required to match the settings ID to the name since table entries ref. id +type GitHubProjectBoardFieldSettings struct { + Width string `json:"width"` + Options []struct { + ID string `json:"id"` + Name string `json:"name"` + NameHTML string `json:"name_html"` + } `json:"options"` } -// GithubReportData defines the github report data structure -type GithubReportData map[ColumnTitle][]IssueOverview - -// Marshal used to marshal GithubReportData into bytes -func (d *GithubReportData) Marshal() ([]byte, error) { - return json.Marshal(d) +// This struct represents a graphql query +// that is getting executed using the githubv4 +// graphql library: https://github.com/shurcooL/githubv4 +// for the GitHub graphql api, see: https://docs.github.com/en/issues/trying-out-the-new-projects-experience/using-the-api-to-manage-projects +// ENHANCEMENT: filter via request, see: https://dgraph.io/docs/graphql/queries/search-filtering/ +type ciSignalProjectBoardGraphQLQuery struct { + Node struct { + ProjectNext struct { + // Fields information about the column headers of the project + // --> | Title | Testgrid Board | Testgrid URL | UpdatedAt | ... | + Fields struct { + Nodes []struct { + Name string + Settings string + } + } `graphql:"fields(first: 100)"` + // Items board rows with content + Items struct { + Nodes []struct { + ID string + Title string + FieldValues struct { + Nodes []struct { + Value string + ProjectField struct { + Name string + } + } + } `graphql:"fieldValues(first: 20)"` + Content struct { + Issue struct { + URL string + } `graphql:"... on Issue"` + PullRequest struct { + URL string + } `graphql:"... on PullRequest"` + } + } + } `graphql:"items(first: 100)"` + } `graphql:"... on ProjectNext"` + } `graphql:"node(id: $projectBoardID)"` } -// IssueOverview defines the data types of a github issue in github report data -type IssueOverview struct { - // URL github issue url - URL string `json:"url"` - // ID github issue id - ID int64 `json:"id"` - // Title github issue title - Title string `json:"title"` - // Sigs kubernetes sigs that are referenced via label - Sigs []string `json:"sigs"` -} +type ( + fieldValue string + fieldName string + TransformedProjectBoardItem struct { + ID string + Title string + Fields map[fieldName]fieldValue + } -type issueDetail struct { - Number int64 `json:"number"` - HTMLURL string `json:"html_url"` - Title string `json:"title"` - Labels []github.Label `json:"labels,omitempty"` -} + // Types for project board filtering + FilteredFieldName string + FilteredBlacklistVal string +) // GetGithubReportData used to request the raw report data from github -func GetGithubReportData(cfg Config) (GithubReportData, error) { - ciSignalProjectBoard := []GithubProjectBoardColumn{newColumn, underInvestigationColumn} - - // if the short flag is not set observingColumn & resolvedColumn will be added to the report - if !cfg.ShortReport { - ciSignalProjectBoard = append(ciSignalProjectBoard, observingColumn, resolvedColumn) +func GetGithubReportData(cfg Config, fieldFilter map[FilteredFieldName][]FilteredBlacklistVal) ([]*TransformedProjectBoardItem, error) { + // lookup project board information + var queryCiSignalProjectBoard ciSignalProjectBoardGraphQLQuery + variablesProjectBoardFields := map[string]interface{}{ + "projectBoardID": githubv4.ID(ciSignalProjectBoardID), + } + if err := cfg.GithubClient.Query(context.Background(), &queryCiSignalProjectBoard, variablesProjectBoardFields); err != nil { + return nil, err } - githubReportData := map[ColumnTitle][]IssueOverview{} - for _, column := range ciSignalProjectBoard { - cards, err := getCardsFromColumn(cfg, column.ColumnID) - if err != nil { + // projectBoardFieldIDs hold input IDs of the project board to replace all IDs with names + // Example: The input "Testgrid Board" is of the type "select" + // to enter a value on the project board you can select of defined values + // every value gets an ID assigned, like this: "master-blocking" = 34u5h2l, "master-informing" = 438tz93 + // the information that is looked up on each row references the ID which is cryptic to read + // + // Received row information: { Testgrid Board: 34u5h2l, ... } + // Transformed row information: { Testgrid Board: "master-blocking", ... } + type ( + // verbose types + projectBoardFieldID string + projectBoardFieldName string + ) + projectBoardFieldIDs := map[projectBoardFieldID]projectBoardFieldName{} + + // populate listOfSettingsIDs with IDs + for _, field := range queryCiSignalProjectBoard.Node.ProjectNext.Fields.Nodes { + var fieldSettings GitHubProjectBoardFieldSettings + if err := json.Unmarshal([]byte(field.Settings), &fieldSettings); err != nil { return nil, err } - githubReportData[column.ColumnTitle] = cards - } - return githubReportData, nil -} - -func getCardsFromColumn(cfg Config, cardsID ColumnID) ([]IssueOverview, error) { - opt := &github.ProjectCardListOptions{} - cards, _, err := cfg.GithubClient.Projects.ListProjectCards(context.Background(), int64(cardsID), opt) - if err != nil { - return nil, errors.Wrap(err, "querying cards") + for _, option := range fieldSettings.Options { + projectBoardFieldIDs[projectBoardFieldID(option.ID)] = projectBoardFieldName(option.Name) + } } - issues := []IssueOverview{} - for _, c := range cards { - issueDetail, err := getIssueDetail(cfg, *c.ContentURL) - if err != nil { - return nil, err + transformedProjectBoardItems := []*TransformedProjectBoardItem{} + for _, item := range queryCiSignalProjectBoard.Node.ProjectNext.Items.Nodes { + transFields := map[fieldName]fieldValue{} + itemBlacklisted := false + for _, field := range item.FieldValues.Nodes { + fieldVal := field.Value + // To check if the field value is blacklisted + // in the case of a ID stored in the field + // this must be replaced first with the projectBoardFieldIDs map + if val, ok := projectBoardFieldIDs[projectBoardFieldID(field.Value)]; ok { + // ID detected replace ID with Name + fieldVal = string(val) + } + // check if field name is a filtered field + // with the filter map it is possible to filter the results + // example: "Status" field gets filtered with blacklist values, "RESOLVED" + // no "Status": "RESOLVED" items will be added to the output + if blacklistValues, filteredFieldFound := fieldFilter[FilteredFieldName(field.ProjectField.Name)]; filteredFieldFound { + // The field is a filtered field since it could be found in the fieldFilter map + // check if the value of the field is blacklisted + for _, bv := range blacklistValues { + if fieldVal == string(bv) { + itemBlacklisted = true + break + } + } + if itemBlacklisted { + break + } + } + transFields[fieldName(field.ProjectField.Name)] = fieldValue(fieldVal) } - - overview := IssueOverview{ - URL: issueDetail.HTMLURL, - ID: issueDetail.Number, - Title: issueDetail.Title, + if itemBlacklisted { + continue } - for _, v := range issueDetail.Labels { - sig := sigRegex.FindString(*v.Name) - if sig != "" { - sig = strings.Replace(sig, "/", " ", 1) - overview.Sigs = append(overview.Sigs, sig) - } + if item.Content.Issue.URL != "" { + transFields[fieldName("Issue URL")] = fieldValue(item.Content.Issue.URL) } - issues = append(issues, overview) - } - return issues, nil -} - -func getIssueDetail(cfg Config, url string) (*issueDetail, error) { - // Create a new request using http - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, errors.Wrap(err, "creating HTTP request") - } - // add authorization header to the req - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", cfg.GithubToken)) - - // Send req using http Client - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, errors.Wrap(err, "getting card details from GitHub") + if item.Content.PullRequest.URL != "" { + transFields[fieldName("PullRequest URL")] = fieldValue(item.Content.PullRequest.URL) + } + transformedProjectBoardItems = append(transformedProjectBoardItems, &TransformedProjectBoardItem{ + ID: item.ID, + Title: item.Title, + Fields: transFields, + }) } - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, errors.Wrap(err, "reading GitHub response data") - } - var result issueDetail - err = json.Unmarshal(body, &result) - if err != nil { - return nil, errors.Wrap(err, "unmarshal GitHub response data") - } - return &result, nil + return transformedProjectBoardItems, nil } diff --git a/cmd/ci-reporter/cmd/root.go b/cmd/ci-reporter/cmd/root.go index 15048b55e62..96501df057f 100644 --- a/cmd/ci-reporter/cmd/root.go +++ b/cmd/ci-reporter/cmd/root.go @@ -23,9 +23,9 @@ import ( "strings" "time" - "github.com/google/go-github/v34/github" "github.com/olekukonko/tablewriter" "github.com/pkg/errors" + "github.com/shurcooL/githubv4" "github.com/spf13/cobra" "github.com/tj/go-spin" ) @@ -50,7 +50,7 @@ func Execute() error { // Config configuration that is getting injected into ci-signal report functions type Config struct { - GithubClient *github.Client + GithubClient *githubv4.Client GithubToken string ReleaseVersion string ShortReport bool @@ -58,14 +58,7 @@ type Config struct { Filepath string } -var cfg = &Config{ - GithubClient: &github.Client{}, - GithubToken: "", - ReleaseVersion: "", - ShortReport: false, - JSONOutput: false, - Filepath: "", -} +var cfg = &Config{} func init() { rootCmd.Flags().StringVarP(&cfg.ReleaseVersion, "release-version", "v", "", "Specify a Kubernetes release versions like '1.22' which will populate the report additionally") @@ -126,13 +119,13 @@ type CIReporterName string // CIReportRecord generic report data format type CIReportRecord struct { - ID string `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - Category string `json:"category"` - Sigs []string `json:"sigs"` - Status string `json:"status"` - CreatedTimestamp string `json:"created_timestamp"` + Title string `json:"title"` + TestgridBoard string `json:"testgrid_board"` + URL string `json:"url"` + Status string `json:"status"` + StatusDetails string `json:"status_details"` + CreatedTimestamp string `json:"created_timestamp"` + UpdatedTimestamp string `json:"updated_timestamp"` } // @@ -241,20 +234,25 @@ func PrintReporterData(cfg *Config, reports *CIReportDataFields) error { // table in short version differs from regular table if cfg.ShortReport { - table.SetHeader([]string{"ID", "TITLE", "CATEGORY", "STATUS"}) + table.SetHeader([]string{"TESTGRID BOARD", "TITLE", "STATUS", "STATUS DETAILS"}) for _, record := range r.Records { - data = append(data, []string{record.ID, record.Title, record.Category, record.Status}) + data = append(data, []string{record.TestgridBoard, record.Title, record.Status, record.StatusDetails}) } } else { - table.SetHeader([]string{"ID", "TITLE", "CATEGORY", "STATUS", "SIGS", "URL", "TS"}) + table.SetHeader([]string{"TESTGRID BOARD", "TITLE", "STATUS", "STATUS DETAILS", "URL", "UPDATED AT"}) for _, record := range r.Records { - data = append(data, []string{record.ID, record.Title, record.Category, record.Status, fmt.Sprintf("%v", record.Sigs), record.URL, record.CreatedTimestamp}) + data = append(data, []string{ + record.TestgridBoard, + record.Title, record.Status, + record.StatusDetails, + record.URL, + strings.ReplaceAll(record.UpdatedTimestamp, "T00:00:00+00:00", ""), + }) } } table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) table.AppendBulk(data) table.SetCenterSeparator("|") - table.SetAutoMergeCells(true) table.Render() // write a summary diff --git a/cmd/ci-reporter/cmd/testgrid.go b/cmd/ci-reporter/cmd/testgrid.go index 9ffd7bf5023..c74cbcb0cf4 100644 --- a/cmd/ci-reporter/cmd/testgrid.go +++ b/cmd/ci-reporter/cmd/testgrid.go @@ -62,13 +62,13 @@ func (r TestgridReporter) CollectReportData(cfg *Config) ([]*CIReportRecord, err jobSummary := jobData[jobName] if !cfg.ShortReport || jobSummary.OverallStatus != testgrid.Passing { records = append(records, &CIReportRecord{ - ID: string(dashboardName), + TestgridBoard: string(dashboardName), Title: string(jobName), URL: jobSummary.GetJobURL(jobName), - Category: string(jobSummary.OverallStatus), - Sigs: jobSummary.FilterSigs(), - Status: jobSummary.FilterSuccessRateForLastRuns(), + Status: string(jobSummary.OverallStatus), + StatusDetails: jobSummary.FilterSuccessRateForLastRuns(), CreatedTimestamp: time.Unix(jobSummary.LastRunTimestamp, 0).Format("2006-01-02 15:04:05 CET"), + UpdatedTimestamp: time.Unix(jobSummary.LastUpdateTimestamp, 0).Format("2006-01-02 15:04:05 CET"), }) } } diff --git a/go.mod b/go.mod index f506fd3b111..1168924a1bb 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,6 @@ require ( github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7 // indirect github.com/google/go-cmp v0.5.7 // indirect github.com/google/go-github/v33 v33.0.0 // indirect - github.com/google/go-github/v34 v34.0.0 github.com/google/go-querystring v1.1.0 // indirect github.com/googleapis/gax-go/v2 v2.1.1 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -90,6 +89,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect + github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect github.com/spiegel-im-spiegel/errs v1.0.5 // indirect github.com/xanzy/go-gitlab v0.43.0 // indirect github.com/xanzy/ssh-agent v0.3.0 // indirect @@ -118,6 +118,7 @@ require ( github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/secure-systems-lab/go-securesystemslib v0.3.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2 github.com/spf13/pflag v1.0.5 // indirect github.com/vbatts/tar-split v0.11.2 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect diff --git a/go.sum b/go.sum index 26cef6b295d..72c3f6699eb 100644 --- a/go.sum +++ b/go.sum @@ -531,8 +531,6 @@ github.com/google/go-containerregistry v0.7.1-0.20211118220127-abdc633f8305 h1:4 github.com/google/go-containerregistry v0.7.1-0.20211118220127-abdc633f8305/go.mod h1:6cMIl1RfryEiPzBE67OgtZdEiLWz4myqCQIiBMy3CsM= github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM= github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg= -github.com/google/go-github/v34 v34.0.0 h1:/siYFImY8KwGc5QD1gaPf+f8QX6tLwxNIco2RkYxoFA= -github.com/google/go-github/v34 v34.0.0/go.mod h1:w/2qlrXUfty+lbyO6tatnzIw97v1CM+/jZcwXMDiPQQ= github.com/google/go-github/v39 v39.1.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= @@ -959,8 +957,12 @@ github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lz github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks= github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY= github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= +github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2 h1:82EIpiGB79OIPgSGa63Oj4Ipf+YAX1c6A9qjmEYoRXc= +github.com/shurcooL/githubv4 v0.0.0-20220115235240-a14260e6f8a2/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= +github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=