Merge pull request #5588 from heaths/issue5587
status: ignore FORBIDDEN errors due to SAML enforcement
This commit is contained in:
commit
f3099828f8
4 changed files with 535 additions and 262 deletions
|
|
@ -1,185 +1,153 @@
|
|||
{
|
||||
"data": {
|
||||
"assignments": {
|
||||
"edges": [
|
||||
"nodes": [
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-03-01T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Board up RPD windows",
|
||||
"number": 73,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
"updatedAt": "2022-03-01T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Board up RPD windows",
|
||||
"number": 73,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2022-03-10T21:33:57Z",
|
||||
"title": "yolo",
|
||||
"number": 157,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2022-03-10T21:33:57Z",
|
||||
"title": "yolo",
|
||||
"number": 157,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-01-06T05:05:12Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Issue Frecency",
|
||||
"number": 4768,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
"updatedAt": "2022-01-06T05:05:12Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Issue Frecency",
|
||||
"number": 4768,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2021-08-05T20:03:07Z",
|
||||
"title": "Reducing zombie threat in Raccoon City",
|
||||
"number": 514,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2021-08-05T20:03:07Z",
|
||||
"title": "Reducing zombie threat in Raccoon City",
|
||||
"number": 514,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2021-10-16T23:20:35Z",
|
||||
"title": "Repo garden for Windows",
|
||||
"number": 3223,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2021-10-16T23:20:35Z",
|
||||
"title": "Repo garden for Windows",
|
||||
"number": 3223,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2020-11-10T23:07:37Z",
|
||||
"title": "welp",
|
||||
"number": 74,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2020-11-10T23:07:37Z",
|
||||
"title": "welp",
|
||||
"number": 74,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-07-20T01:30:01Z",
|
||||
"title": "Ability to send emails from helpdesk UI",
|
||||
"number": 42,
|
||||
"repository": {
|
||||
"nameWithOwner": "tildetown/tildetown-admin"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-07-20T01:30:01Z",
|
||||
"title": "Ability to send emails from helpdesk UI",
|
||||
"number": 42,
|
||||
"repository": {
|
||||
"nameWithOwner": "tildetown/tildetown-admin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-07-22T19:37:45Z",
|
||||
"title": "complete move to class based views",
|
||||
"number": 22,
|
||||
"repository": {
|
||||
"nameWithOwner": "adreyer/arkestrator"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-07-22T19:37:45Z",
|
||||
"title": "complete move to class based views",
|
||||
"number": 22,
|
||||
"repository": {
|
||||
"nameWithOwner": "adreyer/arkestrator"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-02-20T17:54:24Z",
|
||||
"title": "`(every)` macro",
|
||||
"number": 145,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/tildemush"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-02-20T17:54:24Z",
|
||||
"title": "`(every)` macro",
|
||||
"number": 145,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/tildemush"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-02-20T18:10:53Z",
|
||||
"title": "audit move rules",
|
||||
"number": 79,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/tildemush"
|
||||
}
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2019-02-20T18:10:53Z",
|
||||
"title": "audit move rules",
|
||||
"number": 79,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/tildemush"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewRequested": {
|
||||
"edges": [
|
||||
"nodes": [
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-02-18T19:38:20Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Foobar",
|
||||
"number": 1234,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
"updatedAt": "2022-02-18T19:38:20Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Foobar",
|
||||
"number": 1234,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2022-02-04T06:56:48Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Welcome party for Leon",
|
||||
"number": 50,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
"updatedAt": "2022-02-04T06:56:48Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Welcome party for Leon",
|
||||
"number": 50,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2021-12-20T10:30:39Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Haircut for Leon",
|
||||
"number": 49,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
"updatedAt": "2021-12-20T10:30:39Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Haircut for Leon",
|
||||
"number": 49,
|
||||
"repository": {
|
||||
"nameWithOwner": "rpd/todo"
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"updatedAt": "2021-12-20T16:43:15Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "This pull request adds support for json output the to `gh pr checks` …",
|
||||
"number": 4671,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
"updatedAt": "2021-12-20T16:43:15Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "This pull request adds support for json output the to `gh pr checks` …",
|
||||
"number": 4671,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
98
pkg/cmd/status/fixtures/search_forbidden.json
Normal file
98
pkg/cmd/status/fixtures/search_forbidden.json
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
{
|
||||
"data": {
|
||||
"assignments": {
|
||||
"nodes": [
|
||||
{
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
null,
|
||||
{
|
||||
"__typename": "Issue",
|
||||
"updatedAt": "2022-03-10T21:33:57Z",
|
||||
"title": "yolo",
|
||||
"number": 157,
|
||||
"repository": {
|
||||
"nameWithOwner": "vilmibm/testing"
|
||||
}
|
||||
},
|
||||
null
|
||||
]
|
||||
},
|
||||
"reviewRequested": {
|
||||
"nodes": [
|
||||
{
|
||||
"updatedAt": "2022-03-15T17:10:25Z",
|
||||
"__typename": "PullRequest",
|
||||
"title": "Pin extensions",
|
||||
"number": 5272,
|
||||
"repository": {
|
||||
"nameWithOwner": "cli/cli"
|
||||
}
|
||||
},
|
||||
null
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{
|
||||
"type": "FORBIDDEN",
|
||||
"path": [
|
||||
"assignments",
|
||||
"nodes",
|
||||
1
|
||||
],
|
||||
"extensions": {
|
||||
"saml_failure": true
|
||||
},
|
||||
"locations": [
|
||||
{
|
||||
"line": 4,
|
||||
"column": 5
|
||||
}
|
||||
],
|
||||
"message": "Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization."
|
||||
},
|
||||
{
|
||||
"type": "FORBIDDEN",
|
||||
"path": [
|
||||
"assignments",
|
||||
"nodes",
|
||||
3
|
||||
],
|
||||
"extensions": {
|
||||
"saml_failure": true
|
||||
},
|
||||
"locations": [
|
||||
{
|
||||
"line": 4,
|
||||
"column": 5
|
||||
}
|
||||
],
|
||||
"message": "Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization."
|
||||
},
|
||||
{
|
||||
"type": "FORBIDDEN",
|
||||
"path": [
|
||||
"reviewRequested",
|
||||
"nodes",
|
||||
1
|
||||
],
|
||||
"extensions": {
|
||||
"saml_failure": true
|
||||
},
|
||||
"locations": [
|
||||
{
|
||||
"line": 4,
|
||||
"column": 5
|
||||
}
|
||||
],
|
||||
"message": "Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -2,19 +2,23 @@ package status
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/pkg/cmd/factory"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
|
@ -93,6 +97,7 @@ type Notification struct {
|
|||
}
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
index int
|
||||
}
|
||||
|
||||
type StatusItem struct {
|
||||
|
|
@ -100,6 +105,7 @@ type StatusItem struct {
|
|||
Identifier string // eg cli/cli#1234 or just 1234
|
||||
preview string // eg This is the truncated body of something...
|
||||
Reason string // only used in repo activity
|
||||
index int
|
||||
}
|
||||
|
||||
func (s StatusItem) Preview() string {
|
||||
|
|
@ -155,6 +161,12 @@ func (rs Results) Swap(i, j int) {
|
|||
rs[i], rs[j] = rs[j], rs[i]
|
||||
}
|
||||
|
||||
type stringSet interface {
|
||||
Len() int
|
||||
Add(string)
|
||||
ToSlice() []string
|
||||
}
|
||||
|
||||
type StatusGetter struct {
|
||||
Client *http.Client
|
||||
cachedClient func(*http.Client, time.Duration) *http.Client
|
||||
|
|
@ -166,6 +178,12 @@ type StatusGetter struct {
|
|||
Mentions []StatusItem
|
||||
ReviewRequests []StatusItem
|
||||
RepoActivity []StatusItem
|
||||
|
||||
authErrors stringSet
|
||||
authErrorsMu sync.Mutex
|
||||
|
||||
currentUsername string
|
||||
usernameMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStatusGetter(client *http.Client, hostname string, opts *StatusOptions) *StatusGetter {
|
||||
|
|
@ -196,6 +214,13 @@ func (s *StatusGetter) ShouldExclude(repo string) bool {
|
|||
}
|
||||
|
||||
func (s *StatusGetter) CurrentUsername() (string, error) {
|
||||
s.usernameMu.Lock()
|
||||
defer s.usernameMu.Unlock()
|
||||
|
||||
if s.currentUsername != "" {
|
||||
return s.currentUsername, nil
|
||||
}
|
||||
|
||||
cachedClient := s.CachedClient(time.Hour * 48)
|
||||
cachingAPIClient := api.NewClientFromHTTP(cachedClient)
|
||||
currentUsername, err := api.CurrentLoginName(cachingAPIClient, s.hostname())
|
||||
|
|
@ -203,10 +228,11 @@ func (s *StatusGetter) CurrentUsername() (string, error) {
|
|||
return "", fmt.Errorf("failed to get current username: %w", err)
|
||||
}
|
||||
|
||||
s.currentUsername = currentUsername
|
||||
return currentUsername, nil
|
||||
}
|
||||
|
||||
func (s *StatusGetter) ActualMention(n Notification) (string, error) {
|
||||
func (s *StatusGetter) ActualMention(commentURL string) (string, error) {
|
||||
currentUsername, err := s.CurrentUsername()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -219,17 +245,15 @@ func (s *StatusGetter) ActualMention(n Notification) (string, error) {
|
|||
resp := struct {
|
||||
Body string
|
||||
}{}
|
||||
if err := c.REST(s.hostname(), "GET", n.Subject.LatestCommentURL, nil, &resp); err != nil {
|
||||
if err := c.REST(s.hostname(), "GET", commentURL, nil, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var ret string
|
||||
|
||||
if strings.Contains(resp.Body, "@"+currentUsername) {
|
||||
ret = resp.Body
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// These are split up by endpoint since it is along that boundary we parallelize
|
||||
|
|
@ -244,16 +268,63 @@ func (s *StatusGetter) LoadNotifications() error {
|
|||
query.Add("participating", "true")
|
||||
query.Add("all", "true")
|
||||
|
||||
fetchWorkers := 10
|
||||
ctx, abortFetching := context.WithCancel(context.Background())
|
||||
defer abortFetching()
|
||||
toFetch := make(chan Notification)
|
||||
fetched := make(chan StatusItem)
|
||||
|
||||
wg := new(errgroup.Group)
|
||||
for i := 0; i < fetchWorkers; i++ {
|
||||
wg.Go(func() error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case n, ok := <-toFetch:
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if actual, err := s.ActualMention(n.Subject.LatestCommentURL); actual != "" && err == nil {
|
||||
// I'm so sorry
|
||||
split := strings.Split(n.Subject.URL, "/")
|
||||
fetched <- StatusItem{
|
||||
Repository: n.Repository.FullName,
|
||||
Identifier: fmt.Sprintf("%s#%s", n.Repository.FullName, split[len(split)-1]),
|
||||
preview: actual,
|
||||
index: n.index,
|
||||
}
|
||||
} else if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) && httpErr.StatusCode == 403 {
|
||||
s.addAuthError(httpErr.Message, factory.SSOURL())
|
||||
} else {
|
||||
abortFetching()
|
||||
return fmt.Errorf("could not fetch comment: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
go func() {
|
||||
for item := range fetched {
|
||||
s.Mentions = append(s.Mentions, item)
|
||||
}
|
||||
close(doneCh)
|
||||
}()
|
||||
|
||||
// this sucks, having to fetch so much :/ but it was the only way in my
|
||||
// testing to really get enough mentions. I would love to be able to just
|
||||
// filter for mentions but it does not seem like the notifications API can
|
||||
// do that. I'd switch to the GraphQL version, but to my knowledge that does
|
||||
// not work with PATs right now.
|
||||
var ns []Notification
|
||||
var resp []Notification
|
||||
pages := 0
|
||||
nIndex := 0
|
||||
p := fmt.Sprintf("notifications?%s", query.Encode())
|
||||
for pages < 3 {
|
||||
for pages := 0; pages < 3; pages++ {
|
||||
var resp []Notification
|
||||
next, err := c.RESTWithNext(s.hostname(), "GET", p, nil, &resp)
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
|
|
@ -261,45 +332,36 @@ func (s *StatusGetter) LoadNotifications() error {
|
|||
return fmt.Errorf("could not get notifications: %w", err)
|
||||
}
|
||||
}
|
||||
ns = append(ns, resp...)
|
||||
|
||||
for _, n := range resp {
|
||||
if n.Reason != "mention" {
|
||||
continue
|
||||
}
|
||||
if s.Org != "" && n.Repository.Owner.Login != s.Org {
|
||||
continue
|
||||
}
|
||||
if s.ShouldExclude(n.Repository.FullName) {
|
||||
continue
|
||||
}
|
||||
n.index = nIndex
|
||||
nIndex++
|
||||
toFetch <- n
|
||||
}
|
||||
|
||||
if next == "" || len(resp) < perPage {
|
||||
break
|
||||
}
|
||||
|
||||
pages++
|
||||
p = next
|
||||
}
|
||||
|
||||
s.Mentions = []StatusItem{}
|
||||
|
||||
for _, n := range ns {
|
||||
if n.Reason != "mention" {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.Org != "" && n.Repository.Owner.Login != s.Org {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.ShouldExclude(n.Repository.FullName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if actual, err := s.ActualMention(n); actual != "" && err == nil {
|
||||
// I'm so sorry
|
||||
split := strings.Split(n.Subject.URL, "/")
|
||||
s.Mentions = append(s.Mentions, StatusItem{
|
||||
Repository: n.Repository.FullName,
|
||||
Identifier: fmt.Sprintf("%s#%s", n.Repository.FullName, split[len(split)-1]),
|
||||
preview: actual,
|
||||
})
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("could not fetch comment: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
close(toFetch)
|
||||
err := wg.Wait()
|
||||
close(fetched)
|
||||
<-doneCh
|
||||
sort.Slice(s.Mentions, func(i, j int) bool {
|
||||
return s.Mentions[i].index < s.Mentions[j].index
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
const searchQuery = `
|
||||
|
|
@ -323,18 +385,14 @@ fragment pr on PullRequest {
|
|||
}
|
||||
query AssignedSearch($searchAssigns: String!, $searchReviews: String!, $limit: Int = 25) {
|
||||
assignments: search(first: $limit, type: ISSUE, query: $searchAssigns) {
|
||||
edges {
|
||||
node {
|
||||
...issue
|
||||
...pr
|
||||
}
|
||||
nodes {
|
||||
...issue
|
||||
...pr
|
||||
}
|
||||
}
|
||||
reviewRequested: search(first: $limit, type: ISSUE, query: $searchReviews) {
|
||||
edges {
|
||||
node {
|
||||
...pr
|
||||
}
|
||||
nodes {
|
||||
...pr
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
|
@ -360,37 +418,59 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
|
||||
var resp struct {
|
||||
Assignments struct {
|
||||
Edges []struct {
|
||||
Node SearchResult
|
||||
}
|
||||
Nodes []*SearchResult
|
||||
}
|
||||
ReviewRequested struct {
|
||||
Edges []struct {
|
||||
Node SearchResult
|
||||
}
|
||||
Nodes []*SearchResult
|
||||
}
|
||||
}
|
||||
err := c.GraphQL(s.hostname(), searchQuery, variables, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not search for assignments: %w", err)
|
||||
var gqlErrResponse *api.GraphQLErrorResponse
|
||||
if errors.As(err, &gqlErrResponse) {
|
||||
gqlErrors := make([]api.GraphQLError, 0, len(gqlErrResponse.Errors))
|
||||
// Exclude any FORBIDDEN errors and show status for what we can.
|
||||
for _, gqlErr := range gqlErrResponse.Errors {
|
||||
if gqlErr.Type == "FORBIDDEN" {
|
||||
s.addAuthError(gqlErr.Message, factory.SSOURL())
|
||||
} else {
|
||||
gqlErrors = append(gqlErrors, gqlErr)
|
||||
}
|
||||
}
|
||||
if len(gqlErrors) == 0 {
|
||||
err = nil
|
||||
} else {
|
||||
err = &api.GraphQLErrorResponse{Errors: gqlErrors}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not search for assignments: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
prs := []SearchResult{}
|
||||
issues := []SearchResult{}
|
||||
reviewRequested := []SearchResult{}
|
||||
|
||||
for _, e := range resp.Assignments.Edges {
|
||||
if e.Node.Type == "Issue" {
|
||||
issues = append(issues, e.Node)
|
||||
} else if e.Node.Type == "PullRequest" {
|
||||
prs = append(prs, e.Node)
|
||||
} else {
|
||||
panic("you shouldn't be here")
|
||||
for _, node := range resp.Assignments.Nodes {
|
||||
if node == nil {
|
||||
continue // likely due to FORBIDDEN results
|
||||
}
|
||||
switch node.Type {
|
||||
case "Issue":
|
||||
issues = append(issues, *node)
|
||||
case "PullRequest":
|
||||
prs = append(prs, *node)
|
||||
default:
|
||||
return fmt.Errorf("unkown Assignments type: %q", node.Type)
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range resp.ReviewRequested.Edges {
|
||||
reviewRequested = append(reviewRequested, e.Node)
|
||||
for _, node := range resp.ReviewRequested.Nodes {
|
||||
if node == nil {
|
||||
continue // likely due to FORBIDDEN results
|
||||
}
|
||||
reviewRequested = append(reviewRequested, *node)
|
||||
}
|
||||
|
||||
sort.Sort(Results(issues))
|
||||
|
|
@ -506,6 +586,28 @@ func (s *StatusGetter) LoadEvents() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *StatusGetter) addAuthError(msg, ssoURL string) {
|
||||
s.authErrorsMu.Lock()
|
||||
defer s.authErrorsMu.Unlock()
|
||||
|
||||
if s.authErrors == nil {
|
||||
s.authErrors = set.NewStringSet()
|
||||
}
|
||||
|
||||
if ssoURL != "" {
|
||||
s.authErrors.Add(fmt.Sprintf("%s\nAuthorize in your web browser: %s", msg, ssoURL))
|
||||
} else {
|
||||
s.authErrors.Add(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StatusGetter) HasAuthErrors() bool {
|
||||
s.authErrorsMu.Lock()
|
||||
defer s.authErrorsMu.Unlock()
|
||||
|
||||
return s.authErrors != nil && s.authErrors.Len() > 0
|
||||
}
|
||||
|
||||
func statusRun(opts *StatusOptions) error {
|
||||
client, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
|
|
@ -522,36 +624,31 @@ func statusRun(opts *StatusOptions) error {
|
|||
// TODO break out sections into individual subcommands
|
||||
|
||||
g := new(errgroup.Group)
|
||||
g.Go(func() error {
|
||||
if err := sg.LoadNotifications(); err != nil {
|
||||
return fmt.Errorf("could not load notifications: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := sg.LoadEvents(); err != nil {
|
||||
return fmt.Errorf("could not load events: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
g.Go(func() error {
|
||||
if err := sg.LoadSearchResults(); err != nil {
|
||||
return fmt.Errorf("failed to search: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
g.Go(func() error {
|
||||
err := sg.LoadNotifications()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("could not load notifications: %w", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
err := sg.LoadEvents()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("could not load events: %w", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
err := sg.LoadSearchResults()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to search: %w", err)
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
err = g.Wait()
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
out := opts.IO.Out
|
||||
|
|
@ -628,5 +725,13 @@ func statusRun(opts *StatusOptions) error {
|
|||
fmt.Fprintln(out, lipgloss.JoinHorizontal(lipgloss.Top, rrSection, mSection))
|
||||
fmt.Fprintln(out, raSection)
|
||||
|
||||
if sg.HasAuthErrors() {
|
||||
errs := sg.authErrors.ToSlice()
|
||||
sort.Strings(errs)
|
||||
for _, msg := range errs {
|
||||
fmt.Fprintln(out, cs.Gray(fmt.Sprintf("warning: %s", msg)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ package status
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
|
|
@ -96,7 +99,7 @@ func TestStatusRun(t *testing.T) {
|
|||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.StringResponse(`{"data": { "assignments": {"edges": [] }, "reviewRequested": {"edges": []}}}`))
|
||||
httpmock.StringResponse(`{"data": { "assignments": {"nodes": [] }, "reviewRequested": {"nodes": []}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "notifications"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
|
|
@ -110,18 +113,6 @@ func TestStatusRun(t *testing.T) {
|
|||
{
|
||||
name: "something",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
|
|
@ -140,9 +131,6 @@ func TestStatusRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"),
|
||||
httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.FileResponse("./fixtures/search.json"))
|
||||
|
|
@ -159,15 +147,6 @@ func TestStatusRun(t *testing.T) {
|
|||
{
|
||||
name: "exclude a repository",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
|
|
@ -183,9 +162,6 @@ func TestStatusRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"),
|
||||
httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.FileResponse("./fixtures/search.json"))
|
||||
|
|
@ -212,9 +188,6 @@ func TestStatusRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"),
|
||||
httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.FileResponse("./fixtures/search.json"))
|
||||
|
|
@ -235,12 +208,6 @@ func TestStatusRun(t *testing.T) {
|
|||
{
|
||||
name: "filter to an org",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
|
|
@ -253,9 +220,6 @@ func TestStatusRun(t *testing.T) {
|
|||
reg.Register(
|
||||
httpmock.REST("GET", "repos/rpd/todo/issues/comments/1065"),
|
||||
httpmock.StringResponse(`{"body":"not a real mention"}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.FileResponse("./fixtures/search.json"))
|
||||
|
|
@ -271,6 +235,144 @@ func TestStatusRun(t *testing.T) {
|
|||
},
|
||||
wantOut: "Assigned Issues │ Assigned Pull Requests \nvilmibm/testing#157 yolo │ cli/cli#5272 Pin extensions \ncli/cli#3223 Repo garden...│ rpd/todo#73 Board up RPD windows \nrpd/todo#514 Reducing zo...│ cli/cli#4768 Issue Frecency \nvilmibm/testing#74 welp │ \nadreyer/arkestrator#22 complete mo...│ \n │ \nReview Requests │ Mentions \ncli/cli#5272 Pin extensions │ rpd/todo#110 hello @jillvalentine ...\nvilmibm/testing#1234 Foobar │ \nrpd/todo#50 Welcome party...│ \ncli/cli#4671 This pull req...│ \nrpd/todo#49 Haircut for Leon│ \n │ \nRepository Activity\nrpd/todo#5326 new PR Only write UTF-8 BOM on Windows where it is needed\n\n",
|
||||
},
|
||||
{
|
||||
name: "forbidden errors",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/rpd/todo/issues/110"),
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Request: req,
|
||||
StatusCode: 403,
|
||||
Body: ioutil.NopCloser(strings.NewReader(`{"message": "You don't have permission to access this resource."}`)),
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Github-Sso": []string{"required; url=http://click.me/auth"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/rpd/todo/issues/4113"),
|
||||
httpmock.StringResponse(`{"body":"this is a comment"}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/cli/cli/issues/1096"),
|
||||
httpmock.StringResponse(`{"body":"@jillvalentine hi"}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/rpd/todo/issues/comments/1065"),
|
||||
httpmock.StringResponse(`{"body":"not a real mention"}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/vilmibm/gh-screensaver/issues/comments/10"),
|
||||
httpmock.StringResponse(`{"body":"a message for @jillvalentine"}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.FileResponse("./fixtures/search_forbidden.json"))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "notifications"),
|
||||
httpmock.FileResponse("./fixtures/notifications.json"))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "users/jillvalentine/received_events"),
|
||||
httpmock.FileResponse("./fixtures/events.json"))
|
||||
},
|
||||
opts: &StatusOptions{},
|
||||
wantOut: heredoc.Doc(`
|
||||
Assigned Issues │ Assigned Pull Requests
|
||||
vilmibm/testing#157 yolo │ cli/cli#5272 Pin extensions
|
||||
│
|
||||
Review Requests │ Mentions
|
||||
cli/cli#5272 Pin extensions │ cli/cli#1096 @jillval...
|
||||
│ vilmibm/gh-screensaver#15 a messag...
|
||||
|
||||
Repository Activity
|
||||
rpd/todo#5326 new PR Only write UTF-8 BOM on W...
|
||||
vilmibm/testing#5325 comment on Ability to sea... We are working on dedicat...
|
||||
cli/cli#5319 comment on [Codespaces] D... Wondering if we shouldn't...
|
||||
cli/cli#5300 new issue Terminal bell when a runn...
|
||||
|
||||
warning: Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization.
|
||||
warning: You don't have permission to access this resource.
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "notification errors",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/rpd/todo/issues/110"),
|
||||
httpmock.StatusStringResponse(429, `{
|
||||
"message": "Too many requests."
|
||||
}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.StringResponse(`{"data": { "assignments": {"nodes": [] }, "reviewRequested": {"nodes": []}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "notifications"),
|
||||
httpmock.StringResponse(`[
|
||||
{
|
||||
"reason": "mention",
|
||||
"subject": {
|
||||
"title": "Good",
|
||||
"url": "https://api.github.com/repos/rpd/todo/issues/110",
|
||||
"latest_comment_url": "https://api.github.com/repos/rpd/todo/issues/110",
|
||||
"type": "Issue"
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "rpd/todo",
|
||||
"owner": {
|
||||
"login": "rpd"
|
||||
}
|
||||
}
|
||||
}
|
||||
]`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "users/jillvalentine/received_events"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
},
|
||||
opts: &StatusOptions{},
|
||||
wantErrMsg: "could not load notifications: could not fetch comment: HTTP 429 (https://api.github.com/repos/rpd/todo/issues/110)",
|
||||
},
|
||||
{
|
||||
name: "search errors",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL("UserCurrent"),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "jillvalentine"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL("AssignedSearch"),
|
||||
httpmock.StringResponse(`{
|
||||
"data": {
|
||||
"assignments": {
|
||||
"nodes": [
|
||||
null
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{
|
||||
"type": "NOT FOUND",
|
||||
"path": [
|
||||
"assignments",
|
||||
"nodes",
|
||||
0
|
||||
],
|
||||
"message": "Not found."
|
||||
}
|
||||
]
|
||||
}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "notifications"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "users/jillvalentine/received_events"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
},
|
||||
opts: &StatusOptions{},
|
||||
wantErrMsg: "failed to search: could not search for assignments: GraphQL: Not found. (assignments.nodes.0)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue