mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): refactors github pr sync functionality (#22715)
- Adds `_API_BASE_URL` to `CODER_EXTERNAL_AUTH_CONFIG_` - Extracts and refactors existing GitHub PR sync logic to new packages `coderd/gitsync` and `coderd/externalauth/gitprovider` - Associated wiring and tests Created using Opus 4.6
This commit is contained in:
+177
-668
@@ -13,7 +13,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -32,6 +31,8 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
|
||||
"github.com/coder/coder/v2/coderd/gitsync"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
@@ -39,16 +40,15 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
chatDiffStatusTTL = 120 * time.Second
|
||||
chatDiffBackgroundRefreshTimeout = 20 * time.Second
|
||||
githubAPIBaseURL = "https://api.github.com"
|
||||
chatStreamBatchSize = 256
|
||||
chatDiffStatusTTL = gitsync.DiffStatusTTL
|
||||
chatStreamBatchSize = 256
|
||||
|
||||
chatContextLimitModelConfigKey = "context_limit"
|
||||
chatContextCompressionThresholdModelConfigKey = "context_compression_threshold"
|
||||
@@ -58,19 +58,6 @@ const (
|
||||
maxSystemPromptLenBytes = 131072 // 128 KiB
|
||||
)
|
||||
|
||||
// chatDiffRefreshBackoffSchedule defines the delays between successive
|
||||
// background diff refresh attempts. The trigger fires when the agent
|
||||
// obtains a GitHub token, which is typically right before a git push
|
||||
// or PR creation. The backoff gives progressively more time for the
|
||||
// push and any PR workflow to complete before querying the GitHub API.
|
||||
var chatDiffRefreshBackoffSchedule = []time.Duration{
|
||||
1 * time.Second,
|
||||
3 * time.Second,
|
||||
5 * time.Second,
|
||||
10 * time.Second,
|
||||
20 * time.Second,
|
||||
}
|
||||
|
||||
// chatGitRef holds the branch and remote origin reported by the
|
||||
// workspace agent during a git operation.
|
||||
type chatGitRef struct {
|
||||
@@ -78,32 +65,6 @@ type chatGitRef struct {
|
||||
RemoteOrigin string
|
||||
}
|
||||
|
||||
var (
|
||||
githubPullRequestPathPattern = regexp.MustCompile(
|
||||
`^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/pull/([0-9]+)(?:[/?#].*)?$`,
|
||||
)
|
||||
githubRepositoryHTTPSPattern = regexp.MustCompile(
|
||||
`^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`,
|
||||
)
|
||||
githubRepositorySSHPathPattern = regexp.MustCompile(
|
||||
`^(?:ssh://)?git@github\.com[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?/?$`,
|
||||
)
|
||||
)
|
||||
|
||||
type githubPullRequestRef struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
}
|
||||
|
||||
type githubPullRequestStatus struct {
|
||||
PullRequestState string
|
||||
ChangesRequested bool
|
||||
Additions int32
|
||||
Deletions int32
|
||||
ChangedFiles int32
|
||||
}
|
||||
|
||||
type chatRepositoryRef struct {
|
||||
Provider string
|
||||
RemoteOrigin string
|
||||
@@ -1249,193 +1210,6 @@ func shouldRefreshChatDiffStatus(status database.ChatDiffStatus, now time.Time,
|
||||
return chatDiffStatusIsStale(status, now)
|
||||
}
|
||||
|
||||
func (api *API) triggerWorkspaceChatDiffStatusRefresh(workspace database.Workspace, chatID uuid.NullUUID, gitRef chatGitRef) {
|
||||
if workspace.ID == uuid.Nil || workspace.OwnerID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func(workspaceID, workspaceOwnerID uuid.UUID, chatID uuid.NullUUID, gitRef chatGitRef) {
|
||||
ctx := api.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
//nolint:gocritic // Background goroutine for diff status refresh has no user context.
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
|
||||
// Always store the git ref so the data is persisted even
|
||||
// before a PR exists. The frontend can show branch info
|
||||
// and the refresh loop can resolve a PR later.
|
||||
api.storeChatGitRef(ctx, workspaceID, workspaceOwnerID, chatID, gitRef)
|
||||
|
||||
for _, delay := range chatDiffRefreshBackoffSchedule {
|
||||
t := api.Clock.NewTimer(delay, "chat_diff_refresh")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
|
||||
// Refresh and publish status on every iteration.
|
||||
// Stop the loop once a PR is discovered — there's
|
||||
// nothing more to wait for after that.
|
||||
if api.refreshWorkspaceChatDiffStatuses(ctx, workspaceID, workspaceOwnerID, chatID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(workspace.ID, workspace.OwnerID, chatID, gitRef)
|
||||
}
|
||||
|
||||
// storeChatGitRef persists the git branch and remote origin reported
|
||||
// by the workspace agent on the chat that initiated the git operation.
|
||||
// When chatID is set, only that specific chat is updated; otherwise all
|
||||
// chats associated with the workspace are updated (legacy fallback).
|
||||
func (api *API) storeChatGitRef(ctx context.Context, workspaceID, workspaceOwnerID uuid.UUID, chatID uuid.NullUUID, gitRef chatGitRef) {
|
||||
var chatsToUpdate []database.Chat
|
||||
|
||||
if chatID.Valid {
|
||||
chat, err := api.Database.GetChatByID(ctx, chatID.UUID)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to get chat for git ref storage",
|
||||
slog.F("chat_id", chatID.UUID),
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
chatsToUpdate = []database.Chat{chat}
|
||||
} else {
|
||||
chats, err := api.Database.GetChatsByOwnerID(ctx, database.GetChatsByOwnerIDParams{
|
||||
OwnerID: workspaceOwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to list chats for git ref storage",
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
chatsToUpdate = filterChatsByWorkspaceID(chats, workspaceID)
|
||||
}
|
||||
|
||||
for _, chat := range chatsToUpdate {
|
||||
_, err := api.Database.UpsertChatDiffStatusReference(ctx, database.UpsertChatDiffStatusReferenceParams{
|
||||
ChatID: chat.ID,
|
||||
GitBranch: gitRef.Branch,
|
||||
GitRemoteOrigin: gitRef.RemoteOrigin,
|
||||
StaleAt: time.Now().UTC().Add(-time.Second),
|
||||
Url: sql.NullString{},
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to store git ref on chat diff status",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.Error(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
api.publishChatDiffStatusEvent(ctx, chat.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// refreshWorkspaceChatDiffStatuses refreshes the diff status for chats
|
||||
// associated with the given workspace. When chatID is set, only that
|
||||
// specific chat is refreshed; otherwise all chats for the workspace
|
||||
// are refreshed (legacy fallback). It returns true when every
|
||||
// refreshed chat has a PR URL resolved, signaling that the caller
|
||||
// can stop polling.
|
||||
func (api *API) refreshWorkspaceChatDiffStatuses(ctx context.Context, workspaceID, workspaceOwnerID uuid.UUID, chatID uuid.NullUUID) bool {
|
||||
var filtered []database.Chat
|
||||
|
||||
if chatID.Valid {
|
||||
chat, err := api.Database.GetChatByID(ctx, chatID.UUID)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to get chat for diff refresh",
|
||||
slog.F("chat_id", chatID.UUID),
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
filtered = []database.Chat{chat}
|
||||
} else {
|
||||
chats, err := api.Database.GetChatsByOwnerID(ctx, database.GetChatsByOwnerIDParams{
|
||||
OwnerID: workspaceOwnerID,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to list workspace owner chats for diff refresh",
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.F("workspace_owner_id", workspaceOwnerID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false
|
||||
}
|
||||
filtered = filterChatsByWorkspaceID(chats, workspaceID)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
allHavePR := true
|
||||
for _, chat := range filtered {
|
||||
refreshCtx, cancel := context.WithTimeout(ctx, chatDiffBackgroundRefreshTimeout)
|
||||
status, err := api.resolveChatDiffStatusWithOptions(refreshCtx, chat, true)
|
||||
cancel()
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "failed to refresh chat diff status after workspace external auth",
|
||||
slog.F("workspace_id", workspaceID),
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
allHavePR = false
|
||||
} else if status == nil || !status.Url.Valid || strings.TrimSpace(status.Url.String) == "" {
|
||||
allHavePR = false
|
||||
}
|
||||
|
||||
api.publishChatStatusEvent(ctx, chat.ID)
|
||||
api.publishChatDiffStatusEvent(ctx, chat.ID)
|
||||
}
|
||||
|
||||
return allHavePR
|
||||
}
|
||||
|
||||
func filterChatsByWorkspaceID(chats []database.Chat, workspaceID uuid.UUID) []database.Chat {
|
||||
filteredChats := make([]database.Chat, 0, len(chats))
|
||||
for _, chat := range chats {
|
||||
if !chat.WorkspaceID.Valid || chat.WorkspaceID.UUID != workspaceID {
|
||||
continue
|
||||
}
|
||||
filteredChats = append(filteredChats, chat)
|
||||
}
|
||||
return filteredChats
|
||||
}
|
||||
|
||||
func (api *API) publishChatStatusEvent(ctx context.Context, chatID uuid.UUID) {
|
||||
if api.chatDaemon == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.chatDaemon.RefreshStatus(ctx, chatID); err != nil {
|
||||
api.Logger.Debug(ctx, "failed to refresh published chat status",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) publishChatDiffStatusEvent(ctx context.Context, chatID uuid.UUID) {
|
||||
if api.chatDaemon == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := api.chatDaemon.PublishDiffStatusChange(ctx, chatID); err != nil {
|
||||
api.Logger.Debug(ctx, "failed to publish chat diff status change",
|
||||
slog.F("chat_id", chatID),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) resolveChatDiffContents(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
@@ -1483,22 +1257,36 @@ func (api *API) resolveChatDiffContents(
|
||||
if reference.RepositoryRef == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !strings.EqualFold(reference.RepositoryRef.Provider, string(codersdk.EnhancedExternalAuthProviderGitHub)) {
|
||||
|
||||
gp := api.resolveGitProvider(reference.RepositoryRef.RemoteOrigin)
|
||||
if gp == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
token := api.resolveChatGitHubAccessToken(ctx, chat.OwnerID)
|
||||
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
|
||||
if err != nil {
|
||||
return result, xerrors.Errorf("resolve git access token: %w", err)
|
||||
} else if token == nil {
|
||||
return result, xerrors.New("nil git access token")
|
||||
}
|
||||
|
||||
if reference.PullRequestURL != "" {
|
||||
diff, err := api.fetchGitHubPullRequestDiff(ctx, reference.PullRequestURL, token)
|
||||
ref, ok := gp.ParsePullRequestURL(reference.PullRequestURL)
|
||||
if !ok {
|
||||
return result, xerrors.Errorf("invalid pull request URL %q", reference.PullRequestURL)
|
||||
}
|
||||
diff, err := gp.FetchPullRequestDiff(ctx, *token, ref)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Diff = diff
|
||||
return result, nil
|
||||
}
|
||||
|
||||
diff, err := api.fetchGitHubCompareDiff(ctx, *reference.RepositoryRef, token)
|
||||
diff, err := gp.FetchBranchDiff(ctx, *token, gitprovider.BranchRef{
|
||||
Owner: reference.RepositoryRef.Owner,
|
||||
Repo: reference.RepositoryRef.Repo,
|
||||
Branch: reference.RepositoryRef.Branch,
|
||||
})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -1532,34 +1320,53 @@ func (api *API) resolveChatDiffReference(
|
||||
// If we have a repo ref with a branch, try to resolve the
|
||||
// current open PR. This picks up new PRs after the previous
|
||||
// one was closed.
|
||||
if reference.RepositoryRef != nil &&
|
||||
strings.EqualFold(reference.RepositoryRef.Provider, string(codersdk.EnhancedExternalAuthProviderGitHub)) {
|
||||
pullRequestURL, lookupErr := api.resolveGitHubPullRequestURLFromRepositoryRef(ctx, chat.OwnerID, *reference.RepositoryRef)
|
||||
if lookupErr != nil {
|
||||
api.Logger.Debug(ctx, "failed to resolve pull request from repository reference",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", reference.RepositoryRef.Provider),
|
||||
slog.F("remote_origin", reference.RepositoryRef.RemoteOrigin),
|
||||
slog.F("branch", reference.RepositoryRef.Branch),
|
||||
slog.Error(lookupErr),
|
||||
)
|
||||
} else if pullRequestURL != "" {
|
||||
reference.PullRequestURL = pullRequestURL
|
||||
if reference.RepositoryRef != nil && reference.RepositoryRef.Owner != "" {
|
||||
gp := api.resolveGitProvider(reference.RepositoryRef.RemoteOrigin)
|
||||
if gp != nil {
|
||||
token, err := api.resolveChatGitAccessToken(ctx, chat.OwnerID, reference.RepositoryRef.RemoteOrigin)
|
||||
if token == nil || errors.Is(err, gitsync.ErrNoTokenAvailable) {
|
||||
// No token available yet.
|
||||
return reference, nil
|
||||
} else if err != nil {
|
||||
return chatDiffReference{}, xerrors.Errorf("resolve git access token: %w", err)
|
||||
}
|
||||
prRef, lookupErr := gp.ResolveBranchPullRequest(ctx, *token, gitprovider.BranchRef{
|
||||
Owner: reference.RepositoryRef.Owner,
|
||||
Repo: reference.RepositoryRef.Repo,
|
||||
Branch: reference.RepositoryRef.Branch,
|
||||
})
|
||||
if lookupErr != nil {
|
||||
api.Logger.Debug(ctx, "failed to resolve pull request from repository reference",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("provider", reference.RepositoryRef.Provider),
|
||||
slog.F("remote_origin", reference.RepositoryRef.RemoteOrigin),
|
||||
slog.F("branch", reference.RepositoryRef.Branch),
|
||||
slog.Error(lookupErr),
|
||||
)
|
||||
} else if prRef != nil {
|
||||
reference.PullRequestURL = gp.BuildPullRequestURL(*prRef)
|
||||
}
|
||||
reference.PullRequestURL = gp.NormalizePullRequestURL(reference.PullRequestURL)
|
||||
}
|
||||
}
|
||||
|
||||
reference.PullRequestURL = normalizeGitHubPullRequestURL(reference.PullRequestURL)
|
||||
|
||||
// If we have a PR URL but no repo ref (e.g. the agent hasn't
|
||||
// reported branch/origin yet), derive a partial ref from the
|
||||
// PR URL so the caller can still show provider/owner/repo.
|
||||
if reference.RepositoryRef == nil && reference.PullRequestURL != "" {
|
||||
if parsed, ok := parseGitHubPullRequestURL(reference.PullRequestURL); ok {
|
||||
reference.RepositoryRef = &chatRepositoryRef{
|
||||
Provider: string(codersdk.EnhancedExternalAuthProviderGitHub),
|
||||
RemoteOrigin: fmt.Sprintf("https://github.com/%s/%s", parsed.Owner, parsed.Repo),
|
||||
Owner: parsed.Owner,
|
||||
Repo: parsed.Repo,
|
||||
for _, extAuth := range api.ExternalAuthConfigs {
|
||||
gp := extAuth.Git(api.HTTPClient)
|
||||
if gp == nil {
|
||||
continue
|
||||
}
|
||||
if parsed, ok := gp.ParsePullRequestURL(reference.PullRequestURL); ok {
|
||||
reference.RepositoryRef = &chatRepositoryRef{
|
||||
Provider: strings.ToLower(extAuth.Type),
|
||||
Owner: parsed.Owner,
|
||||
Repo: parsed.Repo,
|
||||
RemoteOrigin: gp.BuildRepositoryURL(parsed.Owner, parsed.Repo),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1577,19 +1384,18 @@ func (api *API) buildChatRepositoryRefFromStatus(status database.ChatDiffStatus)
|
||||
return nil
|
||||
}
|
||||
|
||||
providerType, gp := api.resolveExternalAuth(origin)
|
||||
repoRef := &chatRepositoryRef{
|
||||
Provider: strings.TrimSpace(api.resolveExternalAuthProviderType(origin)),
|
||||
Provider: providerType,
|
||||
RemoteOrigin: origin,
|
||||
Branch: branch,
|
||||
}
|
||||
|
||||
if owner, repo, normalizedOrigin, ok := parseGitHubRepositoryOrigin(repoRef.RemoteOrigin); ok {
|
||||
if repoRef.Provider == "" {
|
||||
repoRef.Provider = string(codersdk.EnhancedExternalAuthProviderGitHub)
|
||||
if gp != nil {
|
||||
if owner, repo, normalizedOrigin, ok := gp.ParseRepositoryOrigin(repoRef.RemoteOrigin); ok {
|
||||
repoRef.RemoteOrigin = normalizedOrigin
|
||||
repoRef.Owner = owner
|
||||
repoRef.Repo = repo
|
||||
}
|
||||
repoRef.RemoteOrigin = normalizedOrigin
|
||||
repoRef.Owner = owner
|
||||
repoRef.Repo = repo
|
||||
}
|
||||
|
||||
if repoRef.Provider == "" {
|
||||
@@ -1643,60 +1449,31 @@ func (api *API) getCachedChatDiffStatus(
|
||||
)
|
||||
}
|
||||
|
||||
func (api *API) resolveExternalAuthProviderType(match string) string {
|
||||
match = strings.TrimSpace(match)
|
||||
if match == "" {
|
||||
return ""
|
||||
// resolveExternalAuth finds the external auth config matching the
|
||||
// given remote origin URL and returns both the provider type string
|
||||
// (e.g. "github") and the gitprovider.Provider. Returns ("", nil)
|
||||
// if no matching config is found.
|
||||
func (api *API) resolveExternalAuth(origin string) (providerType string, gp gitprovider.Provider) {
|
||||
origin = strings.TrimSpace(origin)
|
||||
if origin == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
for _, extAuth := range api.ExternalAuthConfigs {
|
||||
if extAuth.Regex == nil || !extAuth.Regex.MatchString(match) {
|
||||
if extAuth.Regex == nil || !extAuth.Regex.MatchString(origin) {
|
||||
continue
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(extAuth.Type))
|
||||
return strings.ToLower(strings.TrimSpace(extAuth.Type)),
|
||||
extAuth.Git(api.HTTPClient)
|
||||
}
|
||||
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func parseGitHubRepositoryOrigin(raw string) (owner string, repo string, normalizedOrigin string, ok bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
matches := githubRepositoryHTTPSPattern.FindStringSubmatch(raw)
|
||||
if len(matches) != 3 {
|
||||
matches = githubRepositorySSHPathPattern.FindStringSubmatch(raw)
|
||||
}
|
||||
if len(matches) != 3 {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
owner = strings.TrimSpace(matches[1])
|
||||
repo = strings.TrimSpace(matches[2])
|
||||
repo = strings.TrimSuffix(repo, ".git")
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
return owner, repo, fmt.Sprintf("https://github.com/%s/%s", owner, repo), true
|
||||
}
|
||||
|
||||
func buildGitHubBranchURL(owner string, repo string, branch string) string {
|
||||
owner = strings.TrimSpace(owner)
|
||||
repo = strings.TrimSpace(repo)
|
||||
branch = strings.TrimSpace(branch)
|
||||
if owner == "" || repo == "" || branch == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"https://github.com/%s/%s/tree/%s",
|
||||
owner,
|
||||
repo,
|
||||
url.PathEscape(branch),
|
||||
)
|
||||
// resolveGitProvider finds the external auth config matching the
|
||||
// given remote origin URL and returns its git provider. Returns
|
||||
// nil if no matching git provider is configured.
|
||||
func (api *API) resolveGitProvider(origin string) gitprovider.Provider {
|
||||
_, gp := api.resolveExternalAuth(origin)
|
||||
return gp
|
||||
}
|
||||
|
||||
func chatDiffStatusIsStale(status database.ChatDiffStatus, now time.Time) bool {
|
||||
@@ -1712,11 +1489,32 @@ func (api *API) refreshChatDiffStatus(
|
||||
chatID uuid.UUID,
|
||||
pullRequestURL string,
|
||||
) (database.ChatDiffStatus, error) {
|
||||
status, err := api.fetchGitHubPullRequestStatus(
|
||||
ctx,
|
||||
pullRequestURL,
|
||||
api.resolveChatGitHubAccessToken(ctx, chatOwnerID),
|
||||
)
|
||||
// Find a provider that can handle this PR URL.
|
||||
var gp gitprovider.Provider
|
||||
var ref gitprovider.PRRef
|
||||
for _, extAuth := range api.ExternalAuthConfigs {
|
||||
p := extAuth.Git(api.HTTPClient)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if parsed, ok := p.ParsePullRequestURL(pullRequestURL); ok {
|
||||
gp = p
|
||||
ref = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
if gp == nil {
|
||||
return database.ChatDiffStatus{}, xerrors.Errorf("no git provider found for PR URL %q", pullRequestURL)
|
||||
}
|
||||
|
||||
origin := gp.BuildRepositoryURL(ref.Owner, ref.Repo)
|
||||
token, err := api.resolveChatGitAccessToken(ctx, chatOwnerID, origin)
|
||||
if err != nil {
|
||||
return database.ChatDiffStatus{}, xerrors.Errorf("resolve git access token: %w", err)
|
||||
} else if token == nil {
|
||||
return database.ChatDiffStatus{}, xerrors.New("nil git access token")
|
||||
}
|
||||
status, err := gp.FetchPullRequestStatus(ctx, *token, ref)
|
||||
if err != nil {
|
||||
return database.ChatDiffStatus{}, err
|
||||
}
|
||||
@@ -1728,13 +1526,13 @@ func (api *API) refreshChatDiffStatus(
|
||||
ChatID: chatID,
|
||||
Url: sql.NullString{String: pullRequestURL, Valid: true},
|
||||
PullRequestState: sql.NullString{
|
||||
String: status.PullRequestState,
|
||||
Valid: status.PullRequestState != "",
|
||||
String: string(status.State),
|
||||
Valid: status.State != "",
|
||||
},
|
||||
ChangesRequested: status.ChangesRequested,
|
||||
Additions: status.Additions,
|
||||
Deletions: status.Deletions,
|
||||
ChangedFiles: status.ChangedFiles,
|
||||
Additions: status.DiffStats.Additions,
|
||||
Deletions: status.DiffStats.Deletions,
|
||||
ChangedFiles: status.DiffStats.ChangedFiles,
|
||||
RefreshedAt: refreshedAt,
|
||||
StaleAt: refreshedAt.Add(chatDiffStatusTTL),
|
||||
},
|
||||
@@ -1745,23 +1543,49 @@ func (api *API) refreshChatDiffStatus(
|
||||
return refreshedStatus, nil
|
||||
}
|
||||
|
||||
func (api *API) resolveChatGitHubAccessToken(
|
||||
func (api *API) resolveChatGitAccessToken(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
) string {
|
||||
// Build a map of provider ID -> config so we can refresh tokens
|
||||
// using the same code path as provisionerdserver.
|
||||
ghConfigs := make(map[string]*externalauth.Config)
|
||||
providerIDs := []string{"github"}
|
||||
for _, config := range api.ExternalAuthConfigs {
|
||||
if !strings.EqualFold(
|
||||
config.Type,
|
||||
string(codersdk.EnhancedExternalAuthProviderGitHub),
|
||||
) {
|
||||
continue
|
||||
origin string,
|
||||
) (*string, error) {
|
||||
origin = strings.TrimSpace(origin)
|
||||
|
||||
// If we have an origin, find the specific matching config first.
|
||||
// This ensures multi-provider setups (github.com + GHE) get the
|
||||
// correct token.
|
||||
if origin != "" {
|
||||
for _, config := range api.ExternalAuthConfigs {
|
||||
if config.Regex == nil || !config.Regex.MatchString(origin) {
|
||||
continue
|
||||
}
|
||||
link, err := api.Database.GetExternalAuthLink(ctx,
|
||||
database.GetExternalAuthLinkParams{
|
||||
ProviderID: config.ID,
|
||||
UserID: userID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
refreshed, refreshErr := config.RefreshToken(ctx, api.Database, link)
|
||||
if refreshErr == nil {
|
||||
link = refreshed
|
||||
}
|
||||
token := strings.TrimSpace(link.OAuthAccessToken)
|
||||
if token != "" {
|
||||
return ptr.Ref(token), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: iterate all external auth configs.
|
||||
// Used when origin is empty (inline refresh from HTTP handler)
|
||||
// or when the origin-specific lookup above failed.
|
||||
configs := make(map[string]*externalauth.Config)
|
||||
providerIDs := []string{}
|
||||
for _, config := range api.ExternalAuthConfigs {
|
||||
providerIDs = append(providerIDs, config.ID)
|
||||
ghConfigs[config.ID] = config
|
||||
configs[config.ID] = config
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
@@ -1785,7 +1609,7 @@ func (api *API) resolveChatGitHubAccessToken(
|
||||
// Refresh the token if there is a matching config, mirroring
|
||||
// the same code path used by provisionerdserver when handing
|
||||
// tokens to provisioners.
|
||||
if cfg, ok := ghConfigs[providerID]; ok {
|
||||
if cfg, ok := configs[providerID]; ok {
|
||||
refreshed, refreshErr := cfg.RefreshToken(ctx, api.Database, link)
|
||||
if refreshErr != nil {
|
||||
api.Logger.Debug(ctx, "failed to refresh external auth token for chat diff",
|
||||
@@ -1802,336 +1626,11 @@ func (api *API) resolveChatGitHubAccessToken(
|
||||
|
||||
token := strings.TrimSpace(link.OAuthAccessToken)
|
||||
if token != "" {
|
||||
return token
|
||||
return ptr.Ref(token), nil
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (api *API) resolveGitHubPullRequestURLFromRepositoryRef(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
repositoryRef chatRepositoryRef,
|
||||
) (string, error) {
|
||||
if repositoryRef.Owner == "" || repositoryRef.Repo == "" || repositoryRef.Branch == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("state", "open")
|
||||
query.Set("head", fmt.Sprintf("%s:%s", repositoryRef.Owner, repositoryRef.Branch))
|
||||
query.Set("sort", "updated")
|
||||
query.Set("direction", "desc")
|
||||
query.Set("per_page", "1")
|
||||
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s/repos/%s/%s/pulls?%s",
|
||||
githubAPIBaseURL,
|
||||
repositoryRef.Owner,
|
||||
repositoryRef.Repo,
|
||||
query.Encode(),
|
||||
)
|
||||
|
||||
var pulls []struct {
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
|
||||
token := api.resolveChatGitHubAccessToken(ctx, userID)
|
||||
if err := api.decodeGitHubJSON(ctx, requestURL, token, &pulls); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(pulls) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return normalizeGitHubPullRequestURL(pulls[0].HTMLURL), nil
|
||||
}
|
||||
|
||||
func (api *API) fetchGitHubPullRequestDiff(
|
||||
ctx context.Context,
|
||||
pullRequestURL string,
|
||||
token string,
|
||||
) (string, error) {
|
||||
ref, ok := parseGitHubPullRequestURL(pullRequestURL)
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("invalid GitHub pull request URL %q", pullRequestURL)
|
||||
}
|
||||
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s/repos/%s/%s/pulls/%d",
|
||||
githubAPIBaseURL,
|
||||
ref.Owner,
|
||||
ref.Repo,
|
||||
ref.Number,
|
||||
)
|
||||
|
||||
return api.fetchGitHubDiff(ctx, requestURL, token)
|
||||
}
|
||||
|
||||
func (api *API) fetchGitHubCompareDiff(
|
||||
ctx context.Context,
|
||||
repositoryRef chatRepositoryRef,
|
||||
token string,
|
||||
) (string, error) {
|
||||
if repositoryRef.Owner == "" || repositoryRef.Repo == "" || repositoryRef.Branch == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var repository struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
repositoryURL := fmt.Sprintf(
|
||||
"%s/repos/%s/%s",
|
||||
githubAPIBaseURL,
|
||||
repositoryRef.Owner,
|
||||
repositoryRef.Repo,
|
||||
)
|
||||
if err := api.decodeGitHubJSON(ctx, repositoryURL, token, &repository); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defaultBranch := strings.TrimSpace(repository.DefaultBranch)
|
||||
if defaultBranch == "" {
|
||||
return "", xerrors.New("github repository default branch is empty")
|
||||
}
|
||||
|
||||
requestURL := fmt.Sprintf(
|
||||
"%s/repos/%s/%s/compare/%s...%s",
|
||||
githubAPIBaseURL,
|
||||
repositoryRef.Owner,
|
||||
repositoryRef.Repo,
|
||||
url.PathEscape(defaultBranch),
|
||||
url.PathEscape(repositoryRef.Branch),
|
||||
)
|
||||
|
||||
return api.fetchGitHubDiff(ctx, requestURL, token)
|
||||
}
|
||||
|
||||
func (api *API) fetchGitHubDiff(
|
||||
ctx context.Context,
|
||||
requestURL string,
|
||||
token string,
|
||||
) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("create github diff request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.diff")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "coder-chat-diff")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
httpClient := api.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("execute github diff request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return "", xerrors.Errorf("github diff request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
return "", xerrors.Errorf(
|
||||
"github diff request failed with status %d: %s",
|
||||
resp.StatusCode,
|
||||
strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
|
||||
diff, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("read github diff response: %w", err)
|
||||
}
|
||||
return string(diff), nil
|
||||
}
|
||||
|
||||
func (api *API) fetchGitHubPullRequestStatus(
|
||||
ctx context.Context,
|
||||
pullRequestURL string,
|
||||
token string,
|
||||
) (githubPullRequestStatus, error) {
|
||||
ref, ok := parseGitHubPullRequestURL(pullRequestURL)
|
||||
if !ok {
|
||||
return githubPullRequestStatus{}, xerrors.Errorf(
|
||||
"invalid GitHub pull request URL %q",
|
||||
pullRequestURL,
|
||||
)
|
||||
}
|
||||
|
||||
pullEndpoint := fmt.Sprintf(
|
||||
"%s/repos/%s/%s/pulls/%d",
|
||||
githubAPIBaseURL,
|
||||
ref.Owner,
|
||||
ref.Repo,
|
||||
ref.Number,
|
||||
)
|
||||
|
||||
var pull struct {
|
||||
State string `json:"state"`
|
||||
Additions int32 `json:"additions"`
|
||||
Deletions int32 `json:"deletions"`
|
||||
ChangedFiles int32 `json:"changed_files"`
|
||||
}
|
||||
if err := api.decodeGitHubJSON(ctx, pullEndpoint, token, &pull); err != nil {
|
||||
return githubPullRequestStatus{}, err
|
||||
}
|
||||
|
||||
var reviews []struct {
|
||||
ID int64 `json:"id"`
|
||||
State string `json:"state"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := api.decodeGitHubJSON(
|
||||
ctx,
|
||||
pullEndpoint+"/reviews?per_page=100",
|
||||
token,
|
||||
&reviews,
|
||||
); err != nil {
|
||||
return githubPullRequestStatus{}, err
|
||||
}
|
||||
|
||||
return githubPullRequestStatus{
|
||||
PullRequestState: strings.ToLower(strings.TrimSpace(pull.State)),
|
||||
ChangesRequested: hasOutstandingGitHubChangesRequested(reviews),
|
||||
Additions: pull.Additions,
|
||||
Deletions: pull.Deletions,
|
||||
ChangedFiles: pull.ChangedFiles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api *API) decodeGitHubJSON(
|
||||
ctx context.Context,
|
||||
requestURL string,
|
||||
token string,
|
||||
dest any,
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create github request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.Header.Set("User-Agent", "coder-chat-diff-status")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
httpClient := api.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("execute github request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
if readErr != nil {
|
||||
return xerrors.Errorf(
|
||||
"github request failed with status %d",
|
||||
resp.StatusCode,
|
||||
)
|
||||
}
|
||||
return xerrors.Errorf(
|
||||
"github request failed with status %d: %s",
|
||||
resp.StatusCode,
|
||||
strings.TrimSpace(string(body)),
|
||||
)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
|
||||
return xerrors.Errorf("decode github response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasOutstandingGitHubChangesRequested(
|
||||
reviews []struct {
|
||||
ID int64 `json:"id"`
|
||||
State string `json:"state"`
|
||||
User struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"user"`
|
||||
},
|
||||
) bool {
|
||||
type reviewerState struct {
|
||||
reviewID int64
|
||||
state string
|
||||
}
|
||||
|
||||
statesByReviewer := make(map[string]reviewerState)
|
||||
for _, review := range reviews {
|
||||
login := strings.ToLower(strings.TrimSpace(review.User.Login))
|
||||
if login == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
state := strings.ToUpper(strings.TrimSpace(review.State))
|
||||
switch state {
|
||||
case "CHANGES_REQUESTED", "APPROVED", "DISMISSED":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
current, exists := statesByReviewer[login]
|
||||
if exists && current.reviewID > review.ID {
|
||||
continue
|
||||
}
|
||||
statesByReviewer[login] = reviewerState{
|
||||
reviewID: review.ID,
|
||||
state: state,
|
||||
}
|
||||
}
|
||||
|
||||
for _, state := range statesByReviewer {
|
||||
if state.state == "CHANGES_REQUESTED" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeGitHubPullRequestURL(raw string) string {
|
||||
ref, ok := parseGitHubPullRequestURL(strings.TrimRight(
|
||||
strings.TrimSpace(raw),
|
||||
"),.;",
|
||||
))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", ref.Owner, ref.Repo, ref.Number)
|
||||
}
|
||||
|
||||
func parseGitHubPullRequestURL(raw string) (githubPullRequestRef, bool) {
|
||||
matches := githubPullRequestPathPattern.FindStringSubmatch(strings.TrimSpace(raw))
|
||||
if len(matches) != 4 {
|
||||
return githubPullRequestRef{}, false
|
||||
}
|
||||
|
||||
number, err := strconv.Atoi(matches[3])
|
||||
if err != nil {
|
||||
return githubPullRequestRef{}, false
|
||||
}
|
||||
|
||||
return githubPullRequestRef{
|
||||
Owner: matches[1],
|
||||
Repo: matches[2],
|
||||
Number: number,
|
||||
}, true
|
||||
return nil, gitsync.ErrNoTokenAvailable
|
||||
}
|
||||
|
||||
type createChatWorkspaceSelection struct {
|
||||
@@ -2786,11 +2285,21 @@ func convertChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) co
|
||||
}
|
||||
}
|
||||
if result.URL == nil {
|
||||
owner, repo, _, ok := parseGitHubRepositoryOrigin(status.GitRemoteOrigin)
|
||||
if ok {
|
||||
branchURL := buildGitHubBranchURL(owner, repo, status.GitBranch)
|
||||
if branchURL != "" {
|
||||
result.URL = &branchURL
|
||||
// Try to build a branch URL from the stored origin.
|
||||
// Since convertChatDiffStatus does not have access to
|
||||
// the API instance, we construct a GitHub provider
|
||||
// directly as a best-effort fallback.
|
||||
// TODO: This uses the default github.com API base URL,
|
||||
// so branch URLs for GitHub Enterprise instances will
|
||||
// be incorrect. To fix this, convertChatDiffStatus
|
||||
// would need access to the external auth configs.
|
||||
gp := gitprovider.New("github", "", nil)
|
||||
if gp != nil {
|
||||
if owner, repo, _, ok := gp.ParseRepositoryOrigin(status.GitRemoteOrigin); ok {
|
||||
branchURL := gp.BuildBranchURL(owner, repo, status.GitBranch)
|
||||
if branchURL != "" {
|
||||
result.URL = &branchURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user