Skip to content
Merged
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
11 changes: 9 additions & 2 deletions backend/internal/adapters/scm/github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservati
// Network/auth/rate-limit failures must surface as Fetched:false.
// Stable terminal states like 404 also surface that way — the PR
// Manager keeps the prior row rather than fabricating closed/merged.
return out, err
return out, scmObserveError(err)
}

out.Draft = rest.Draft
Expand All @@ -117,7 +117,7 @@ func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservati

gq, err := p.fetchGraphQL(ctx, owner, repo, number)
if err != nil {
return out, err
return out, scmObserveError(err)
}

out.CI = ciSummaryFromGraphQL(gq)
Expand Down Expand Up @@ -149,6 +149,13 @@ func (p *Provider) Observe(ctx context.Context, prURL string) (ports.PRObservati
return out, nil
}

func scmObserveError(err error) error {
if errors.Is(err, ErrNotFound) {
return fmt.Errorf("%w: %w", ports.ErrSCMPRNotFound, err)
}
return err
}

// ---------------------------------------------------------------------------
// REST: pull payload
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions backend/internal/cdc/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
EventPRCreated EventType = "pr_created"
EventPRUpdated EventType = "pr_updated"
EventPRCheckRecorded EventType = "pr_check_recorded"
EventPRSessionChanged EventType = "pr_session_changed"
EventPRReviewThreadAdded EventType = "pr_review_thread_added"
EventPRReviewThreadResolved EventType = "pr_review_thread_resolved"
)
Expand Down
8 changes: 8 additions & 0 deletions backend/internal/cli/dto_drift_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ func (f *fakeSessionService) Send(context.Context, domain.SessionID, string) err
return nil
}

func (f *fakeSessionService) ListPRs(context.Context, domain.SessionID) ([]domain.PRFacts, error) {
return nil, nil
}

func (f *fakeSessionService) ClaimPR(context.Context, domain.SessionID, string, sessionsvc.ClaimPROptions) (sessionsvc.ClaimPRResult, error) {
return sessionsvc.ClaimPRResult{}, nil
}

// fakeProjectManager captures the project.AddInput the controller decodes from
// the CLI's request body. Every other method is a no-op so it satisfies the
// projectsvc.Manager interface.
Expand Down
90 changes: 90 additions & 0 deletions backend/internal/cli/pr_ref.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cli

import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
)

func (c *commandContext) resolvePRRef(ctx context.Context, ref string, project projectDetails) (string, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", usageError{errors.New("PR reference must be a github.com PR URL or a number")}
}
if isNumericPRRef(ref) {
repo := strings.TrimSpace(project.Repo)
if repo == "" {
// The daemon must not shell out to external CLIs from its loopback API;
// when the durable project record lacks repo_origin_url, the thin CLI
// does the one-off gh lookup from the registered project checkout and
// sends the daemon a normalized URL.
out, err := c.deps.CommandOutputInDir(ctx, project.Path, "gh", "repo", "view", "--json", "url", "-q", ".url")
if err != nil || strings.TrimSpace(string(out)) == "" {
return "", usageError{errors.New("gh not available; pass the full PR URL")}
}
repo = strings.TrimSpace(string(out))
}
owner, name, err := cliGitHubRepoFromURL(repo)
if err != nil {
return "", usageError{errors.New("PR reference must be a github.com PR URL or a number")}
}
n, _ := strconv.Atoi(strings.TrimPrefix(ref, "#"))
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, name, n), nil
}
owner, name, n, err := cliParseGitHubPRURL(ref)
if err != nil || owner == "" || name == "" || n <= 0 {
return "", usageError{errors.New("PR reference must be a github.com PR URL or a number")}
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", owner, name, n), nil
}

func isNumericPRRef(ref string) bool {
ref = strings.TrimPrefix(strings.TrimSpace(ref), "#")
n, err := strconv.Atoi(ref)
return err == nil && n > 0
}

func cliParseGitHubPRURL(raw string) (string, string, int, error) {
u, err := url.Parse(raw)
if err != nil {
return "", "", 0, err
}
if !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "github.com") {
return "", "", 0, errors.New("not github")
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 4 || parts[2] != "pull" {
return "", "", 0, errors.New("not pr")
}
n, err := strconv.Atoi(parts[3])
if err != nil || n <= 0 {
return "", "", 0, errors.New("bad number")
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), n, nil
}

func cliGitHubRepoFromURL(raw string) (string, string, error) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "git@github.com:") {
parts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(raw, "git@github.com:"), ".git"), "/")
if len(parts) == 2 && parts[0] != "" && parts[1] != "" {
return parts[0], parts[1], nil
}
return "", "", errors.New("bad repo")
}
u, err := url.Parse(raw)
if err != nil {
return "", "", err
}
if !strings.EqualFold(u.Hostname(), "github.com") {
return "", "", errors.New("not github")
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", errors.New("bad repo")
}
return parts[0], strings.TrimSuffix(parts[1], ".git"), nil
}
23 changes: 17 additions & 6 deletions backend/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ type Deps struct {
Out io.Writer
Err io.Writer

HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) error
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
HTTPClient *http.Client
Executable func() (string, error)
StartProcess func(processStartConfig) error
ProcessAlive func(pid int) bool
LookPath func(file string) (string, error)
CommandOutput func(ctx context.Context, name string, args ...string) ([]byte, error)
CommandOutputInDir func(ctx context.Context, dir, name string, args ...string) ([]byte, error)
// DoctorGitHubRESTBase lets tests point the doctor GitHub token probe at
// httptest without mutating package-global state.
DoctorGitHubRESTBase string
Expand All @@ -75,6 +76,7 @@ func DefaultDeps() Deps {
ProcessAlive: processalive.Alive,
LookPath: exec.LookPath,
CommandOutput: commandOutput,
CommandOutputInDir: commandOutputInDir,
DoctorGitHubRESTBase: defaultDoctorGitHubRESTBase,
Now: time.Now,
Sleep: time.Sleep,
Expand All @@ -85,6 +87,12 @@ func commandOutput(ctx context.Context, name string, args ...string) ([]byte, er
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}

func commandOutputInDir(ctx context.Context, dir, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
return cmd.CombinedOutput()
}

func (d Deps) withDefaults() Deps {
def := DefaultDeps()
if d.In == nil {
Expand Down Expand Up @@ -114,6 +122,9 @@ func (d Deps) withDefaults() Deps {
if d.CommandOutput == nil {
d.CommandOutput = def.CommandOutput
}
if d.CommandOutputInDir == nil {
d.CommandOutputInDir = def.CommandOutputInDir
}
if d.DoctorGitHubRESTBase == "" {
d.DoctorGitHubRESTBase = def.DoctorGitHubRESTBase
}
Expand Down
119 changes: 119 additions & 0 deletions backend/internal/cli/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ type sessionCleanupOptions struct {
yes bool
}

type sessionClaimPROptions struct {
project string
json bool
noTakeover bool
}

type sessionRenameRequest struct {
DisplayName string `json:"displayName"`
}
Expand Down Expand Up @@ -78,6 +84,30 @@ type cleanupSessionsResponse struct {
Cleaned []string `json:"cleaned"`
}

type claimPRRequest struct {
PR string `json:"pr"`
AllowTakeover bool `json:"allowTakeover"`
}

type sessionPRDTO struct {
URL string `json:"url"`
Number int `json:"number"`
State string `json:"state"`
CI string `json:"ci"`
Review string `json:"review"`
Mergeability string `json:"mergeability"`
ReviewComments bool `json:"reviewComments"`
UpdatedAt time.Time `json:"updatedAt"`
}

type claimPRResponse struct {
OK bool `json:"ok"`
SessionID string `json:"sessionId"`
PRs []sessionPRDTO `json:"prs"`
BranchChanged bool `json:"branchChanged"`
TakenOverFrom []string `json:"takenOverFrom"`
}

type sessionListEntry struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Expand Down Expand Up @@ -109,6 +139,7 @@ func newSessionCommand(ctx *commandContext) *cobra.Command {
cmd.AddCommand(newSessionRestoreCommand(ctx))
cmd.AddCommand(newSessionRenameCommand(ctx))
cmd.AddCommand(newSessionCleanupCommand(ctx))
cmd.AddCommand(newSessionClaimPRCommand(ctx))
return cmd
}

Expand Down Expand Up @@ -220,6 +251,34 @@ func newSessionCleanupCommand(ctx *commandContext) *cobra.Command {
return cmd
}

func newSessionClaimPRCommand(ctx *commandContext) *cobra.Command {
var opts sessionClaimPROptions
cmd := &cobra.Command{
Use: "claim-pr <session-id> <pr-ref>",
Short: "Attach an existing PR to a session",
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.ExactArgs(2)(cmd, args); err != nil {
return usageError{err}
}
if _, err := normalizeSessionID(args[0]); err != nil {
return err
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
id, err := normalizeSessionID(args[0])
if err != nil {
return err
}
return ctx.claimSessionPR(cmd.Context(), cmd, id, args[1], opts)
},
}
addSessionProjectFlag(cmd.Flags(), &opts.project, "Project id to scope the lookup")
cmd.Flags().BoolVar(&opts.noTakeover, "no-takeover", false, "Refuse if another active session owns the PR")
cmd.Flags().BoolVar(&opts.json, "json", false, "Output as JSON")
return cmd
}

func addSessionProjectFlag(flags interface {
StringVarP(*string, string, string, string, string)
}, target *string, usage string) {
Expand Down Expand Up @@ -249,6 +308,66 @@ func sessionRenameArgs(cmd *cobra.Command, args []string) error {
return nil
}

func (c *commandContext) claimSessionPR(ctx context.Context, cmd *cobra.Command, id, ref string, opts sessionClaimPROptions) error {
sess, err := c.fetchScopedSession(ctx, id, opts.project)
if err != nil {
return err
}
project, err := c.fetchProjectDetails(ctx, sess.ProjectID)
if err != nil {
return err
}
resolvedRef, err := c.resolvePRRef(ctx, ref, project)
if err != nil {
return err
}
var res claimPRResponse
req := claimPRRequest{PR: resolvedRef, AllowTakeover: !opts.noTakeover}
if err := c.postJSON(ctx, "sessions/"+url.PathEscape(id)+"/pr/claim", req, &res); err != nil {
return err
}
if opts.json {
return writeJSON(cmd.OutOrStdout(), res)
}
return writeClaimPRResult(cmd, res)
}

func (c *commandContext) fetchProjectDetails(ctx context.Context, id string) (projectDetails, error) {
var res projectGetResult
if err := c.getJSON(ctx, "projects/"+url.PathEscape(id), &res); err != nil {
return projectDetails{}, err
}
return res.Project, nil
}

func writeClaimPRResult(cmd *cobra.Command, res claimPRResponse) error {
out := cmd.OutOrStdout()
if len(res.PRs) == 0 {
_, err := fmt.Fprintf(out, "session %s claimed PR\n", res.SessionID)
return err
}
pr := res.PRs[0]
if _, err := fmt.Fprintf(out, "session %s claimed PR #%d\n", res.SessionID, pr.Number); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " pr: %s\n", pr.URL); err != nil {
return err
}
checkout := "already on PR branch"
if res.BranchChanged {
checkout = "switched to PR branch"
}
if _, err := fmt.Fprintf(out, " checkout: %s\n", checkout); err != nil {
return err
}
for _, owner := range res.TakenOverFrom {
if _, err := fmt.Fprintf(out, " taking over from %s\n", owner); err != nil {
return err
}
}
return nil
}

func (c *commandContext) listSessions(ctx context.Context, cmd *cobra.Command, opts sessionListOptions) error {
params := url.Values{}
if opts.project != "" {
Expand Down
Loading
Loading