Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions server/plugin/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -779,19 +779,34 @@ func (p *Plugin) getPrsDetails(c *UserContext, w http.ResponseWriter, r *http.Re

prDetails := make([]*PRDetails, len(validPRs))
var wg sync.WaitGroup
var fetchErr error
var fetchErrMu sync.Mutex
recordFetchErr := func(err error) {
fetchErrMu.Lock()
defer fetchErrMu.Unlock()
fetchErr = preferAuthErr(fetchErr, err)
}
for i, pr := range validPRs {
wg.Go(func() {
prDetail := p.fetchPRDetails(c, githubClient, pr.URL, pr.Number)
prDetail, err := p.fetchPRDetails(c, githubClient, pr.URL, pr.Number)
prDetails[i] = prDetail
if err != nil {
recordFetchErr(err)
}
})
}

wg.Wait()

if isGitHubAuthFailure(fetchErr) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: return an HTTP error after handling revocation instead of shipping empty results.

p.handleRevokedToken(c.GHInfo)
p.writeAPIError(w, &APIErrorResponse{ID: "", Message: "Not authorized.", StatusCode: http.StatusUnauthorized})
return
}
p.writeJSON(w, prDetails)
}

func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL string, prNumber int) *PRDetails {
func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL string, prNumber int) (*PRDetails, error) {
var status string
var mergeable bool
// Initialize to a non-nil slice to simplify JSON handling semantics
Expand All @@ -806,16 +821,24 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str
Number: prNumber,
RequestedReviewers: requestedReviewers,
Reviews: reviewsList,
}
}, nil
}

var wg sync.WaitGroup
var fetchErr error
var fetchErrMu sync.Mutex
recordFetchErr := func(err error, msg string) {
c.Log.WithError(err).Warnf("%s", msg)
fetchErrMu.Lock()
defer fetchErrMu.Unlock()
fetchErr = preferAuthErr(fetchErr, err)
}

// Fetch reviews
wg.Go(func() {
fetchedReviews, err := fetchReviews(c, client, repoOwner, repoName, prNumber)
if err != nil {
c.Log.WithError(err).Warnf("Failed to fetch reviews for PR details")
recordFetchErr(err, "Failed to fetch reviews for PR details")
return
}
reviewsList = fetchedReviews
Expand All @@ -825,7 +848,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str
wg.Go(func() {
prInfo, _, err := client.PullRequests.Get(c.Ctx, repoOwner, repoName, prNumber)
if err != nil {
c.Log.WithError(err).Warnf("Failed to fetch PR for PR details")
recordFetchErr(err, "Failed to fetch PR for PR details")
return
}

Expand All @@ -836,7 +859,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str
}
statuses, _, err := client.Repositories.GetCombinedStatus(c.Ctx, repoOwner, repoName, prInfo.GetHead().GetSHA(), nil)
if err != nil {
c.Log.WithError(err).Warnf("Failed to fetch combined status")
recordFetchErr(err, "Failed to fetch combined status")
return
}
status = *statuses.State
Expand All @@ -850,7 +873,7 @@ func (p *Plugin) fetchPRDetails(c *UserContext, client *github.Client, prURL str
Mergeable: mergeable,
RequestedReviewers: requestedReviewers,
Reviews: reviewsList,
}
}, fetchErr
}

func fetchReviews(c *UserContext, client *github.Client, repoOwner string, repoName string, number int) ([]*github.PullRequestReview, error) {
Expand Down Expand Up @@ -1100,18 +1123,18 @@ func (p *Plugin) getLHSData(c *UserContext) (reviewResp []*graphql.GithubPRDetai
graphQLClient := p.graphQLConnect(c.GHInfo)

reviewResp, assignmentResp, openPRResp, err = graphQLClient.GetLHSData(c.Ctx)
if isGitHubAuthFailure(err) {
p.handleRevokedToken(c.GHInfo)
}
if err != nil {
return []*graphql.GithubPRDetails{}, []*github.Issue{}, []*graphql.GithubPRDetails{}, err
return reviewResp, assignmentResp, openPRResp, err
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return reviewResp, assignmentResp, openPRResp, nil
}

func (p *Plugin) getSidebarData(c *UserContext) (*SidebarContent, error) {
reviewResp, assignmentResp, openPRResp, err := p.getLHSData(c)
if err != nil {
return nil, err
}

p.enrichReviewsWithSLAStart(reviewResp, c.GHInfo.GitHubUsername)

Expand All @@ -1120,16 +1143,19 @@ func (p *Plugin) getSidebarData(c *UserContext) (*SidebarContent, error) {
Assignments: assignmentResp,
Reviews: reviewResp,
Unreads: p.getUnreadsData(c),
}, nil
}, err
}

func (p *Plugin) getSidebarContent(c *UserContext, w http.ResponseWriter, r *http.Request) {
sidebarContent, err := p.getSidebarData(c)
if err != nil {
if err != nil && !isGitHubAuthFailure(err) {
c.Log.WithError(err).Errorf("Failed to search for the sidebar data")
p.writeAPIError(w, &APIErrorResponse{Message: "failed to search for the sidebar data", StatusCode: http.StatusInternalServerError})
return
}
if err != nil {
c.Log.WithError(err).Warnf("Returning partial sidebar data after GitHub auth failure")
}

p.writeJSON(w, sidebarContent)
}
Expand Down
52 changes: 48 additions & 4 deletions server/plugin/graphql/lhs_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ package graphql

import (
"context"
"errors"
"fmt"
"net/http"
"strings"

"github.com/google/go-github/v54/github"
"github.com/pkg/errors"
pkgerrors "github.com/pkg/errors"
"github.com/shurcooL/githubv4"
)

Expand Down Expand Up @@ -36,19 +39,60 @@ func (c *Client) GetLHSData(ctx context.Context) ([]*GithubPRDetails, []*github.
var resultAssignee []*github.Issue
var resultReview, resultOpenPR []*GithubPRDetails

var err error
var firstErr error
for _, org := range orgsList {
var err error
resultReview, resultAssignee, resultOpenPR, err = c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, org, c.username)
if err != nil {
c.logger.Error("Error fetching LHS data for org", "org", org, "error", err.Error())
firstErr = preferAuthErr(firstErr, err)
}
}

if len(orgsList) == 0 {
return c.fetchLHSData(ctx, resultReview, resultAssignee, resultOpenPR, "", c.username)
}

return resultReview, resultAssignee, resultOpenPR, nil
// Return partial results alongside the error so callers can detect auth failures
// while still rendering whatever orgs succeeded.
return resultReview, resultAssignee, resultOpenPR, firstErr
}

func preferAuthErr(existing, candidate error) error {
if existing == nil {
return candidate
}
if candidate == nil {
return existing
}
if isLikelyAuthErr(candidate) && !isLikelyAuthErr(existing) {
return candidate
}
return existing
}

func isLikelyAuthErr(err error) bool {
if err == nil {
return false
}
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response != nil {
if ghErr.Response.StatusCode == http.StatusUnauthorized {
return true
}
if ghErr.Response.StatusCode == http.StatusForbidden {
if ghErr.Response.Header.Get("X-GitHub-SSO") != "" {
return true
}
if strings.Contains(strings.ToLower(ghErr.Message), "saml") {
return true
}
}
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "bad credentials") ||
strings.Contains(msg, "non-200 ok status code: 401") ||
strings.Contains(msg, "saml")
}

func (c *Client) fetchLHSData(
Expand Down Expand Up @@ -82,7 +126,7 @@ func (c *Client) fetchLHSData(

for !allReviewRequestsFetched || !allAssignmentsFetched || !allOpenPRsFetched {
if err := c.executeQuery(ctx, &mainQuery, params); err != nil {
return nil, nil, nil, errors.Wrap(err, "Not able to execute the query")
return nil, nil, nil, pkgerrors.Wrap(err, "Not able to execute the query")
}

if !allReviewRequestsFetched {
Expand Down
66 changes: 65 additions & 1 deletion server/plugin/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -1349,13 +1349,77 @@ func (p *Plugin) useGitHubClient(info *GitHubUserInfo, toRun func(info *GitHubUs
p.client.Log.Warn("Error occurred while using the Github client", "error", err.Error())
}

if err != nil && strings.Contains(err.Error(), invalidTokenError) {
if isGitHubAuthFailure(err) {
p.handleRevokedToken(info)
}

return err
}

// isGitHubAuthFailure reports whether err indicates the stored OAuth token is no
// longer usable: a 401, or a 403 from SAML SSO enforcement.
//
// We use two detection paths because the plugin talks to GitHub through both the
// REST client (go-github) and the GraphQL client (githubv4), and they surface
// errors differently.
func isGitHubAuthFailure(err error) bool {
if err == nil {
return false
}

// REST API: go-github returns a typed *github.ErrorResponse with the HTTP status.
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response != nil {
switch ghErr.Response.StatusCode {
case http.StatusUnauthorized:
return true
case http.StatusForbidden:
// Not every 403 is an auth failure; only SAML SSO revocation requires reconnect.
return isSAMLError(ghErr)
}
}

// GraphQL API: githubv4 returns untyped errors with status and message embedded
// in err.Error(), so match on known substrings instead.
errMsg := err.Error()
if strings.Contains(errMsg, invalidTokenError) {
return true
}
if strings.Contains(errMsg, "non-200 OK status code: 401") {
return true
}
// Match SAML content, not bare 403 — unrelated permission errors also return 403.
return strings.Contains(errMsg, "SAML enforcement") || strings.Contains(errMsg, "saml_failure")
}

func preferAuthErr(existing, candidate error) error {
if existing == nil {
return candidate
}
if candidate == nil {
return existing
}
if isGitHubAuthFailure(candidate) && !isGitHubAuthFailure(existing) {
return candidate
}
return existing
}

func isSAMLError(ghErr *github.ErrorResponse) bool {
if ghErr.Response.Header.Get("X-GitHub-SSO") != "" {
return true
}
if strings.Contains(ghErr.Message, "SAML") {
return true
}
for _, e := range ghErr.Errors {
if strings.Contains(e.Message, "SAML") {
return true
}
}
return false
}

func (p *Plugin) handleRevokedToken(info *GitHubUserInfo) {
p.disconnectGitHubAccount(info.UserID)
p.CreateBotDMPost(info.UserID, "Your Github account was disconnected due to an invalid or revoked authorization token. Reconnect your account using the `/github connect` command.", "custom_git_revoked_token")
Expand Down
Loading
Loading