diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go new file mode 100644 index 00000000..ae3361f5 --- /dev/null +++ b/appview/pulls/pulls.go @@ -0,0 +1,2107 @@ +package pulls + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/oauth" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/reporesolver" + "tangled.sh/tangled.sh/core/knotclient" + "tangled.sh/tangled.sh/core/patchutil" + "tangled.sh/tangled.sh/core/types" + + "github.com/bluekeyes/go-gitdiff/gitdiff" + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/posthog/posthog-go" +) + +type Pulls struct { + oauth *oauth.OAuth + repoResolver *reporesolver.RepoResolver + pages *pages.Pages + resolver *appview.Resolver + db *db.DB + config *appview.Config + posthog posthog.Client +} + +func New(oauth *oauth.OAuth, repoResolver *reporesolver.RepoResolver, pages *pages.Pages, resolver *appview.Resolver, db *db.DB, config *appview.Config) *Pulls { + return &Pulls{oauth: oauth, repoResolver: repoResolver, pages: pages, resolver: resolver, db: db, config: config} +} + +// htmx fragment +func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + // can be nil if this pull is not stacked + stack, _ := r.Context().Value("stack").(db.Stack) + + roundNumberStr := chi.URLParam(r, "round") + roundNumber, err := strconv.Atoi(roundNumberStr) + if err != nil { + roundNumber = pull.LastRoundNumber() + } + if roundNumber >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + mergeCheckResponse := s.mergeCheck(f, pull, stack) + resubmitResult := pages.Unknown + if user.Did == pull.OwnerDid { + resubmitResult = s.resubmitCheck(f, pull, stack) + } + + s.pages.PullActionsFragment(w, pages.PullActionsParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Pull: pull, + RoundNumber: roundNumber, + MergeCheck: mergeCheckResponse, + ResubmitCheck: resubmitResult, + Stack: stack, + }) + return + } +} + +func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + // can be nil if this pull is not stacked + stack, _ := r.Context().Value("stack").(db.Stack) + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) + + totalIdents := 1 + for _, submission := range pull.Submissions { + totalIdents += len(submission.Comments) + } + + identsToResolve := make([]string, totalIdents) + + // populate idents + identsToResolve[0] = pull.OwnerDid + idx := 1 + for _, submission := range pull.Submissions { + for _, comment := range submission.Comments { + identsToResolve[idx] = comment.OwnerDid + idx += 1 + } + } + + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + mergeCheckResponse := s.mergeCheck(f, pull, stack) + resubmitResult := pages.Unknown + if user != nil && user.Did == pull.OwnerDid { + resubmitResult = s.resubmitCheck(f, pull, stack) + } + + s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + DidHandleMap: didHandleMap, + Pull: pull, + Stack: stack, + AbandonedPulls: abandonedPulls, + MergeCheck: mergeCheckResponse, + ResubmitCheck: resubmitResult, + }) +} + +func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { + if pull.State == db.PullMerged { + return types.MergeCheckResponse{} + } + + secret, err := db.GetRegistrationKey(s.db, f.Knot) + if err != nil { + log.Printf("failed to get registration key: %v", err) + return types.MergeCheckResponse{ + Error: "failed to check merge status: this knot is unregistered", + } + } + + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) + if err != nil { + log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) + return types.MergeCheckResponse{ + Error: "failed to check merge status", + } + } + + patch := pull.LatestPatch() + if pull.IsStacked() { + // combine patches of substack + subStack := stack.Below(pull) + // collect the portion of the stack that is mergeable + mergeable := subStack.Mergeable() + // combine each patch + patch = mergeable.CombinedPatch() + } + + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) + if err != nil { + log.Println("failed to check for mergeability:", err) + return types.MergeCheckResponse{ + Error: "failed to check merge status", + } + } + switch resp.StatusCode { + case 404: + return types.MergeCheckResponse{ + Error: "failed to check merge status: this knot does not support PRs", + } + case 400: + return types.MergeCheckResponse{ + Error: "failed to check merge status: does this knot support PRs?", + } + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Println("failed to read merge check response body") + return types.MergeCheckResponse{ + Error: "failed to check merge status: knot is not speaking the right language", + } + } + defer resp.Body.Close() + + var mergeCheckResponse types.MergeCheckResponse + err = json.Unmarshal(respBody, &mergeCheckResponse) + if err != nil { + log.Println("failed to unmarshal merge check response", err) + return types.MergeCheckResponse{ + Error: "failed to check merge status: knot is not speaking the right language", + } + } + + return mergeCheckResponse +} + +func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { + if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { + return pages.Unknown + } + + var knot, ownerDid, repoName string + + if pull.PullSource.RepoAt != nil { + // fork-based pulls + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) + if err != nil { + log.Println("failed to get source repo", err) + return pages.Unknown + } + + knot = sourceRepo.Knot + ownerDid = sourceRepo.Did + repoName = sourceRepo.Name + } else { + // pulls within the same repo + knot = f.Knot + ownerDid = f.OwnerDid() + repoName = f.RepoName + } + + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to setup client for %s; ignoring: %v", knot, err) + return pages.Unknown + } + + result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) + if err != nil { + log.Println("failed to reach knotserver", err) + return pages.Unknown + } + + latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev + + if pull.IsStacked() && stack != nil { + top := stack[0] + latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev + } + + log.Println(latestSourceRev, result.Branch.Hash) + + if latestSourceRev != result.Branch.Hash { + return pages.ShouldResubmit + } + + return pages.ShouldNotResubmit +} + +func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + stack, _ := r.Context().Value("stack").(db.Stack) + + roundId := chi.URLParam(r, "round") + roundIdInt, err := strconv.Atoi(roundId) + if err != nil || roundIdInt >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + identsToResolve := []string{pull.OwnerDid} + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + patch := pull.Submissions[roundIdInt].Patch + diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) + + s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ + LoggedInUser: user, + DidHandleMap: didHandleMap, + RepoInfo: f.RepoInfo(user), + Pull: pull, + Stack: stack, + Round: roundIdInt, + Submission: pull.Submissions[roundIdInt], + Diff: &diff, + }) + +} + +func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to get pull.") + return + } + + roundId := chi.URLParam(r, "round") + roundIdInt, err := strconv.Atoi(roundId) + if err != nil || roundIdInt >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + if roundIdInt == 0 { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("cannot interdiff initial submission") + return + } + + identsToResolve := []string{pull.OwnerDid} + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) + if err != nil { + log.Println("failed to interdiff; current patch malformed") + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") + return + } + + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) + if err != nil { + log.Println("failed to interdiff; previous patch malformed") + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") + return + } + + interdiff := patchutil.Interdiff(previousPatch, currentPatch) + + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ + LoggedInUser: s.oauth.GetUser(r), + RepoInfo: f.RepoInfo(user), + Pull: pull, + Round: roundIdInt, + DidHandleMap: didHandleMap, + Interdiff: interdiff, + }) + return +} + +func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + roundId := chi.URLParam(r, "round") + roundIdInt, err := strconv.Atoi(roundId) + if err != nil || roundIdInt >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + identsToResolve := []string{pull.OwnerDid} + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) +} + +func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + params := r.URL.Query() + + state := db.PullOpen + switch params.Get("state") { + case "closed": + state = db.PullClosed + case "merged": + state = db.PullMerged + } + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pulls, err := db.GetPulls( + s.db, + db.FilterEq("repo_at", f.RepoAt), + db.FilterEq("state", state), + ) + if err != nil { + log.Println("failed to get pulls", err) + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") + return + } + + for _, p := range pulls { + var pullSourceRepo *db.Repo + if p.PullSource != nil { + if p.PullSource.RepoAt != nil { + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) + if err != nil { + log.Printf("failed to get repo by at uri: %v", err) + continue + } else { + p.PullSource.Repo = pullSourceRepo + } + } + } + } + + identsToResolve := make([]string, len(pulls)) + for i, pull := range pulls { + identsToResolve[i] = pull.OwnerDid + } + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + s.pages.RepoPulls(w, pages.RepoPullsParams{ + LoggedInUser: s.oauth.GetUser(r), + RepoInfo: f.RepoInfo(user), + Pulls: pulls, + DidHandleMap: didHandleMap, + FilteringBy: state, + }) + return +} + +func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + roundNumberStr := chi.URLParam(r, "round") + roundNumber, err := strconv.Atoi(roundNumberStr) + if err != nil || roundNumber >= len(pull.Submissions) { + http.Error(w, "bad round id", http.StatusBadRequest) + log.Println("failed to parse round id", err) + return + } + + switch r.Method { + case http.MethodGet: + s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Pull: pull, + RoundNumber: roundNumber, + }) + return + case http.MethodPost: + body := r.FormValue("body") + if body == "" { + s.pages.Notice(w, "pull", "Comment body is required") + return + } + + // Start a transaction + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start transaction", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + defer tx.Rollback() + + createdAt := time.Now().Format(time.RFC3339) + ownerDid := user.Did + + pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) + if err != nil { + log.Println("failed to get pull at", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + + atUri := f.RepoAt.String() + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoPullCommentNSID, + Repo: user.Did, + Rkey: appview.TID(), + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoPullComment{ + Repo: &atUri, + Pull: string(pullAt), + Owner: &ownerDid, + Body: body, + CreatedAt: createdAt, + }, + }, + }) + if err != nil { + log.Println("failed to create pull comment", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + + // Create the pull comment in the database with the commentAt field + commentId, err := db.NewPullComment(tx, &db.PullComment{ + OwnerDid: user.Did, + RepoAt: f.RepoAt.String(), + PullId: pull.PullId, + Body: body, + CommentAt: atResp.Uri, + SubmissionId: pull.Submissions[roundNumber].ID, + }) + if err != nil { + log.Println("failed to create pull comment", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Println("failed to commit transaction", err) + s.pages.Notice(w, "pull-comment", "Failed to create comment.") + return + } + + if !s.config.Core.Dev { + err = s.posthog.Enqueue(posthog.Capture{ + DistinctId: user.Did, + Event: "new_pull_comment", + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, + }) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) + return + } +} + +func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + switch r.Method { + case http.MethodGet: + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client for %s", f.Knot) + s.pages.Error503(w) + return + } + + result, err := us.Branches(f.OwnerDid(), f.RepoName) + if err != nil { + log.Println("failed to fetch branches", err) + return + } + + // can be one of "patch", "branch" or "fork" + strategy := r.URL.Query().Get("strategy") + // ignored if strategy is "patch" + sourceBranch := r.URL.Query().Get("sourceBranch") + targetBranch := r.URL.Query().Get("targetBranch") + + s.pages.RepoNewPull(w, pages.RepoNewPullParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Branches: result.Branches, + Strategy: strategy, + SourceBranch: sourceBranch, + TargetBranch: targetBranch, + Title: r.URL.Query().Get("title"), + Body: r.URL.Query().Get("body"), + }) + + case http.MethodPost: + title := r.FormValue("title") + body := r.FormValue("body") + targetBranch := r.FormValue("targetBranch") + fromFork := r.FormValue("fork") + sourceBranch := r.FormValue("sourceBranch") + patch := r.FormValue("patch") + + if targetBranch == "" { + s.pages.Notice(w, "pull", "Target branch is required.") + return + } + + // Determine PR type based on input parameters + isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" + isForkBased := fromFork != "" && sourceBranch != "" + isPatchBased := patch != "" && !isBranchBased && !isForkBased + isStacked := r.FormValue("isStacked") == "on" + + if isPatchBased && !patchutil.IsFormatPatch(patch) { + if title == "" { + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") + return + } + } + + // Validate we have at least one valid PR creation method + if !isBranchBased && !isPatchBased && !isForkBased { + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") + return + } + + // Can't mix branch-based and patch-based approaches + if isBranchBased && patch != "" { + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") + return + } + + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") + return + } + + caps, err := us.Capabilities() + if err != nil { + log.Println("error fetching knot caps", f.Knot, err) + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") + return + } + + if !caps.PullRequests.FormatPatch { + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") + return + } + + // Handle the PR creation based on the type + if isBranchBased { + if !caps.PullRequests.BranchSubmissions { + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") + return + } + s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) + } else if isForkBased { + if !caps.PullRequests.ForkSubmissions { + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") + return + } + s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) + } else if isPatchBased { + if !caps.PullRequests.PatchSubmissions { + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") + return + } + s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) + } + return + } +} + +func (s *Pulls) handleBranchBasedPull( + w http.ResponseWriter, + r *http.Request, + f *reporesolver.ResolvedRepo, + user *oauth.User, + title, + body, + targetBranch, + sourceBranch string, + isStacked bool, +) { + pullSource := &db.PullSource{ + Branch: sourceBranch, + } + recordPullSource := &tangled.RepoPull_Source{ + Branch: sourceBranch, + } + + // Generate a patch using /compare + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create signed client for %s: %s", f.Knot, err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) + if err != nil { + log.Println("failed to compare", err) + s.pages.Notice(w, "pull", err.Error()) + return + } + + sourceRev := comparison.Rev2 + patch := comparison.Patch + + if !patchutil.IsPatchValid(patch) { + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") + return + } + + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) +} + +func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { + if !patchutil.IsPatchValid(patch) { + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") + return + } + + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) +} + +func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { + fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) + if errors.Is(err, sql.ErrNoRows) { + s.pages.Notice(w, "pull", "No such fork.") + return + } else if err != nil { + log.Println("failed to fetch fork:", err) + s.pages.Notice(w, "pull", "Failed to fetch fork.") + return + } + + secret, err := db.GetRegistrationKey(s.db, fork.Knot) + if err != nil { + log.Println("failed to fetch registration key:", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) + if err != nil { + log.Println("failed to create signed client:", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) + if err != nil { + log.Println("failed to create unsigned client:", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) + if err != nil { + log.Println("failed to create hidden ref:", err, resp.StatusCode) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + switch resp.StatusCode { + case 404: + case 400: + s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") + return + } + + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: + // hiddenRef: hidden/feature-1/main (on repo-fork) + // targetBranch: main (on repo-1) + // sourceBranch: feature-1 (on repo-fork) + comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) + if err != nil { + log.Println("failed to compare across branches", err) + s.pages.Notice(w, "pull", err.Error()) + return + } + + sourceRev := comparison.Rev2 + patch := comparison.Patch + + if !patchutil.IsPatchValid(patch) { + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") + return + } + + forkAtUri, err := syntax.ParseATURI(fork.AtUri) + if err != nil { + log.Println("failed to parse fork AT URI", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ + Branch: sourceBranch, + RepoAt: &forkAtUri, + }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked) +} + +func (s *Pulls) createPullRequest( + w http.ResponseWriter, + r *http.Request, + f *reporesolver.ResolvedRepo, + user *oauth.User, + title, body, targetBranch string, + patch string, + sourceRev string, + pullSource *db.PullSource, + recordPullSource *tangled.RepoPull_Source, + isStacked bool, +) { + if isStacked { + // creates a series of PRs, each linking to the previous, identified by jj's change-id + s.createStackedPulLRequest( + w, + r, + f, + user, + targetBranch, + patch, + sourceRev, + pullSource, + ) + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start tx") + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + defer tx.Rollback() + + // We've already checked earlier if it's diff-based and title is empty, + // so if it's still empty now, it's intentionally skipped owing to format-patch. + if title == "" { + formatPatches, err := patchutil.ExtractPatches(patch) + if err != nil { + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) + return + } + if len(formatPatches) == 0 { + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") + return + } + + title = formatPatches[0].Title + body = formatPatches[0].Body + } + + rkey := appview.TID() + initialSubmission := db.PullSubmission{ + Patch: patch, + SourceRev: sourceRev, + } + err = db.NewPull(tx, &db.Pull{ + Title: title, + Body: body, + TargetBranch: targetBranch, + OwnerDid: user.Did, + RepoAt: f.RepoAt, + Rkey: rkey, + Submissions: []*db.PullSubmission{ + &initialSubmission, + }, + PullSource: pullSource, + }) + if err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + pullId, err := db.NextPullId(tx, f.RepoAt) + if err != nil { + log.Println("failed to get pull id", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoPullNSID, + Repo: user.Did, + Rkey: rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoPull{ + Title: title, + PullId: int64(pullId), + TargetRepo: string(f.RepoAt), + TargetBranch: targetBranch, + Patch: patch, + Source: recordPullSource, + }, + }, + }) + if err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + if err = tx.Commit(); err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + if !s.config.Core.Dev { + err = s.posthog.Enqueue(posthog.Capture{ + DistinctId: user.Did, + Event: "new_pull", + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, + }) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) +} + +func (s *Pulls) createStackedPulLRequest( + w http.ResponseWriter, + r *http.Request, + f *reporesolver.ResolvedRepo, + user *oauth.User, + targetBranch string, + patch string, + sourceRev string, + pullSource *db.PullSource, +) { + // run some necessary checks for stacked-prs first + + // must be branch or fork based + if sourceRev == "" { + log.Println("stacked PR from patch-based pull") + s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") + return + } + + formatPatches, err := patchutil.ExtractPatches(patch) + if err != nil { + log.Println("failed to extract patches", err) + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) + return + } + + // must have atleast 1 patch to begin with + if len(formatPatches) == 0 { + log.Println("empty patches") + s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") + return + } + + // build a stack out of this patch + stackId := uuid.New() + stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) + if err != nil { + log.Println("failed to create stack", err) + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + // apply all record creations at once + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem + for _, p := range stack { + record := p.AsRecord() + write := comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ + Collection: tangled.RepoPullNSID, + Rkey: &p.Rkey, + Value: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }, + } + writes = append(writes, &write) + } + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ + Repo: user.Did, + Writes: writes, + }) + if err != nil { + log.Println("failed to create stacked pull request", err) + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") + return + } + + // create all pulls at once + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start tx") + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + defer tx.Rollback() + + for _, p := range stack { + err = db.NewPull(tx, p) + if err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + } + + if err = tx.Commit(); err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) +} + +func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { + _, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + patch := r.FormValue("patch") + if patch == "" { + s.pages.Notice(w, "patch-error", "Patch is required.") + return + } + + if patch == "" || !patchutil.IsPatchValid(patch) { + s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") + return + } + + if patchutil.IsFormatPatch(patch) { + s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") + } else { + s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") + } +} + +func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ + RepoInfo: f.RepoInfo(user), + }) +} + +func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client for %s", f.Knot) + s.pages.Error503(w) + return + } + + result, err := us.Branches(f.OwnerDid(), f.RepoName) + if err != nil { + log.Println("failed to reach knotserver", err) + return + } + + branches := result.Branches + sort.Slice(branches, func(i int, j int) bool { + return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) + }) + + withoutDefault := []types.Branch{} + for _, b := range branches { + if b.IsDefault { + continue + } + withoutDefault = append(withoutDefault, b) + } + + s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ + RepoInfo: f.RepoInfo(user), + Branches: withoutDefault, + }) +} + +func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + forks, err := db.GetForksByDid(s.db, user.Did) + if err != nil { + log.Println("failed to get forks", err) + return + } + + s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ + RepoInfo: f.RepoInfo(user), + Forks: forks, + Selected: r.URL.Query().Get("fork"), + }) +} + +func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + forkVal := r.URL.Query().Get("fork") + + // fork repo + repo, err := db.GetRepo(s.db, user.Did, forkVal) + if err != nil { + log.Println("failed to get repo", user.Did, forkVal) + return + } + + sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client for %s", repo.Knot) + s.pages.Error503(w) + return + } + + sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) + if err != nil { + log.Println("failed to reach knotserver for source branches", err) + return + } + + targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client for target knot %s", f.Knot) + s.pages.Error503(w) + return + } + + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) + if err != nil { + log.Println("failed to reach knotserver for target branches", err) + return + } + + sourceBranches := sourceResult.Branches + sort.Slice(sourceBranches, func(i int, j int) bool { + return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) + }) + + s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ + RepoInfo: f.RepoInfo(user), + SourceBranches: sourceBranches, + TargetBranches: targetResult.Branches, + }) +} + +func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + switch r.Method { + case http.MethodGet: + s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ + RepoInfo: f.RepoInfo(user), + Pull: pull, + }) + return + case http.MethodPost: + if pull.IsPatchBased() { + s.resubmitPatch(w, r) + return + } else if pull.IsBranchBased() { + s.resubmitBranch(w, r) + return + } else if pull.IsForkBased() { + s.resubmitFork(w, r) + return + } + } +} + +func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + if user.Did != pull.OwnerDid { + log.Println("unauthorized user") + w.WriteHeader(http.StatusUnauthorized) + return + } + + patch := r.FormValue("patch") + + s.resubmitPullHelper(w, r, f, user, pull, patch, "") +} + +func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") + return + } + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + if user.Did != pull.OwnerDid { + log.Println("unauthorized user") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if !f.RepoInfo(user).Roles.IsPushAllowed() { + log.Println("unauthorized user") + w.WriteHeader(http.StatusUnauthorized) + return + } + + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create client for %s: %s", f.Knot, err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) + if err != nil { + log.Printf("compare request failed: %s", err) + s.pages.Notice(w, "resubmit-error", err.Error()) + return + } + + sourceRev := comparison.Rev2 + patch := comparison.Patch + + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) +} + +func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") + return + } + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + if user.Did != pull.OwnerDid { + log.Println("unauthorized user") + w.WriteHeader(http.StatusUnauthorized) + return + } + + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) + if err != nil { + log.Println("failed to get source repo", err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + // extract patch by performing compare + ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) + if err != nil { + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + // update the hidden tracking branch to latest + signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) + if err != nil || resp.StatusCode != http.StatusNoContent { + log.Printf("failed to update tracking branch: %s", err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) + if err != nil { + log.Printf("failed to compare branches: %s", err) + s.pages.Notice(w, "resubmit-error", err.Error()) + return + } + + sourceRev := comparison.Rev2 + patch := comparison.Patch + + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) +} + +// validate a resubmission against a pull request +func validateResubmittedPatch(pull *db.Pull, patch string) error { + if patch == "" { + return fmt.Errorf("Patch is empty.") + } + + if patch == pull.LatestPatch() { + return fmt.Errorf("Patch is identical to previous submission.") + } + + if !patchutil.IsPatchValid(patch) { + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") + } + + return nil +} + +func (s *Pulls) resubmitPullHelper( + w http.ResponseWriter, + r *http.Request, + f *reporesolver.ResolvedRepo, + user *oauth.User, + pull *db.Pull, + patch string, + sourceRev string, +) { + if pull.IsStacked() { + log.Println("resubmitting stacked PR") + s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) + return + } + + if err := validateResubmittedPatch(pull, patch); err != nil { + s.pages.Notice(w, "resubmit-error", err.Error()) + return + } + + // validate sourceRev if branch/fork based + if pull.IsBranchBased() || pull.IsForkBased() { + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") + return + } + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start tx") + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + defer tx.Rollback() + + err = db.ResubmitPull(tx, pull, patch, sourceRev) + if err != nil { + log.Println("failed to create pull request", err) + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to authorize client") + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) + if err != nil { + // failed to get record + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") + return + } + + var recordPullSource *tangled.RepoPull_Source + if pull.IsBranchBased() { + recordPullSource = &tangled.RepoPull_Source{ + Branch: pull.PullSource.Branch, + } + } + if pull.IsForkBased() { + repoAt := pull.PullSource.RepoAt.String() + recordPullSource = &tangled.RepoPull_Source{ + Branch: pull.PullSource.Branch, + Repo: &repoAt, + } + } + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoPullNSID, + Repo: user.Did, + Rkey: pull.Rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoPull{ + Title: pull.Title, + PullId: int64(pull.PullId), + TargetRepo: string(f.RepoAt), + TargetBranch: pull.TargetBranch, + Patch: patch, // new patch + Source: recordPullSource, + }, + }, + }) + if err != nil { + log.Println("failed to update record", err) + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") + return + } + + if err = tx.Commit(); err != nil { + log.Println("failed to commit transaction", err) + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) + return +} + +func (s *Pulls) resubmitStackedPullHelper( + w http.ResponseWriter, + r *http.Request, + f *reporesolver.ResolvedRepo, + user *oauth.User, + pull *db.Pull, + patch string, + stackId string, +) { + targetBranch := pull.TargetBranch + + origStack, _ := r.Context().Value("stack").(db.Stack) + newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) + if err != nil { + log.Println("failed to create resubmitted stack", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + // find the diff between the stacks, first, map them by changeId + origById := make(map[string]*db.Pull) + newById := make(map[string]*db.Pull) + for _, p := range origStack { + origById[p.ChangeId] = p + } + for _, p := range newStack { + newById[p.ChangeId] = p + } + + // commits that got deleted: corresponding pull is closed + // commits that got added: new pull is created + // commits that got updated: corresponding pull is resubmitted & new round begins + // + // for commits that were unchanged: no changes, parent-change-id is updated as necessary + additions := make(map[string]*db.Pull) + deletions := make(map[string]*db.Pull) + unchanged := make(map[string]struct{}) + updated := make(map[string]struct{}) + + // pulls in orignal stack but not in new one + for _, op := range origStack { + if _, ok := newById[op.ChangeId]; !ok { + deletions[op.ChangeId] = op + } + } + + // pulls in new stack but not in original one + for _, np := range newStack { + if _, ok := origById[np.ChangeId]; !ok { + additions[np.ChangeId] = np + } + } + + // NOTE: this loop can be written in any of above blocks, + // but is written separately in the interest of simpler code + for _, np := range newStack { + if op, ok := origById[np.ChangeId]; ok { + // pull exists in both stacks + // TODO: can we avoid reparse? + origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) + newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) + + origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) + newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) + + patchutil.SortPatch(newFiles) + patchutil.SortPatch(origFiles) + + // text content of patch may be identical, but a jj rebase might have forwarded it + // + // we still need to update the hash in submission.Patch and submission.SourceRev + if patchutil.Equal(newFiles, origFiles) && + origHeader.Title == newHeader.Title && + origHeader.Body == newHeader.Body { + unchanged[op.ChangeId] = struct{}{} + } else { + updated[op.ChangeId] = struct{}{} + } + } + } + + tx, err := s.db.Begin() + if err != nil { + log.Println("failed to start transaction", err) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + defer tx.Rollback() + + // pds updates to make + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem + + // deleted pulls are marked as deleted in the DB + for _, p := range deletions { + err := db.DeletePull(tx, p.RepoAt, p.PullId) + if err != nil { + log.Println("failed to delete pull", err, p.PullId) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ + Collection: tangled.RepoPullNSID, + Rkey: p.Rkey, + }, + }) + } + + // new pulls are created + for _, p := range additions { + err := db.NewPull(tx, p) + if err != nil { + log.Println("failed to create pull", err, p.PullId) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + + record := p.AsRecord() + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ + Collection: tangled.RepoPullNSID, + Rkey: &p.Rkey, + Value: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }, + }) + } + + // updated pulls are, well, updated; to start a new round + for id := range updated { + op, _ := origById[id] + np, _ := newById[id] + + submission := np.Submissions[np.LastRoundNumber()] + + // resubmit the old pull + err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) + + if err != nil { + log.Println("failed to update pull", err, op.PullId) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + + record := op.AsRecord() + record.Patch = submission.Patch + + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ + Collection: tangled.RepoPullNSID, + Rkey: op.Rkey, + Value: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }, + }) + } + + // unchanged pulls are edited without starting a new round + // + // update source-revs & patches without advancing rounds + for changeId := range unchanged { + op, _ := origById[changeId] + np, _ := newById[changeId] + + origSubmission := op.Submissions[op.LastRoundNumber()] + newSubmission := np.Submissions[np.LastRoundNumber()] + + log.Println("moving unchanged change id : ", changeId) + + err := db.UpdatePull( + tx, + newSubmission.Patch, + newSubmission.SourceRev, + db.FilterEq("id", origSubmission.ID), + ) + + if err != nil { + log.Println("failed to update pull", err, op.PullId) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + + record := op.AsRecord() + record.Patch = newSubmission.Patch + + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ + Collection: tangled.RepoPullNSID, + Rkey: op.Rkey, + Value: &lexutil.LexiconTypeDecoder{ + Val: &record, + }, + }, + }) + } + + // update parent-change-id relations for the entire stack + for _, p := range newStack { + err := db.SetPullParentChangeId( + tx, + p.ParentChangeId, + // these should be enough filters to be unique per-stack + db.FilterEq("repo_at", p.RepoAt.String()), + db.FilterEq("owner_did", p.OwnerDid), + db.FilterEq("change_id", p.ChangeId), + ) + + if err != nil { + log.Println("failed to update pull", err, p.PullId) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + } + + err = tx.Commit() + if err != nil { + log.Println("failed to resubmit pull", err) + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to authorize client") + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") + return + } + + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ + Repo: user.Did, + Writes: writes, + }) + if err != nil { + log.Println("failed to create stacked pull request", err) + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) + return +} + +func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to resolve repo:", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") + return + } + + var pullsToMerge db.Stack + pullsToMerge = append(pullsToMerge, pull) + if pull.IsStacked() { + stack, ok := r.Context().Value("stack").(db.Stack) + if !ok { + log.Println("failed to get stack") + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") + return + } + + // combine patches of substack + subStack := stack.StrictlyBelow(pull) + // collect the portion of the stack that is mergeable + mergeable := subStack.Mergeable() + // add to total patch + pullsToMerge = append(pullsToMerge, mergeable...) + } + + patch := pullsToMerge.CombinedPatch() + + secret, err := db.GetRegistrationKey(s.db, f.Knot) + if err != nil { + log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) + if err != nil { + log.Printf("resolving identity: %s", err) + w.WriteHeader(http.StatusNotFound) + return + } + + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) + if err != nil { + log.Printf("failed to get primary email: %s", err) + } + + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) + if err != nil { + log.Printf("failed to create signed client for %s: %s", f.Knot, err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + // Merge the pull request + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) + if err != nil { + log.Printf("failed to merge pull request: %s", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + if resp.StatusCode != http.StatusOK { + log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + tx, err := s.db.Begin() + if err != nil { + log.Println("failed to start transcation", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + defer tx.Rollback() + + for _, p := range pullsToMerge { + err := db.MergePull(tx, f.RepoAt, p.PullId) + if err != nil { + log.Printf("failed to update pull request status in database: %s", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + } + + err = tx.Commit() + if err != nil { + // TODO: this is unsound, we should also revert the merge from the knotserver here + log.Printf("failed to update pull request status in database: %s", err) + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) +} + +func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("malformed middleware") + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + // auth filter: only owner or collaborators can close + roles := f.RolesInRepo(user) + isCollaborator := roles.IsCollaborator() + isPullAuthor := user.Did == pull.OwnerDid + isCloseAllowed := isCollaborator || isPullAuthor + if !isCloseAllowed { + log.Println("failed to close pull") + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") + return + } + + // Start a transaction + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start transaction", err) + s.pages.Notice(w, "pull-close", "Failed to close pull.") + return + } + defer tx.Rollback() + + var pullsToClose []*db.Pull + pullsToClose = append(pullsToClose, pull) + + // if this PR is stacked, then we want to close all PRs below this one on the stack + if pull.IsStacked() { + stack := r.Context().Value("stack").(db.Stack) + subStack := stack.StrictlyBelow(pull) + pullsToClose = append(pullsToClose, subStack...) + } + + for _, p := range pullsToClose { + // Close the pull in the database + err = db.ClosePull(tx, f.RepoAt, p.PullId) + if err != nil { + log.Println("failed to close pull", err) + s.pages.Notice(w, "pull-close", "Failed to close pull.") + return + } + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Println("failed to commit transaction", err) + s.pages.Notice(w, "pull-close", "Failed to close pull.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) + return +} + +func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { + user := s.oauth.GetUser(r) + + f, err := s.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to resolve repo", err) + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") + return + } + + pull, ok := r.Context().Value("pull").(*db.Pull) + if !ok { + log.Println("failed to get pull") + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") + return + } + + // auth filter: only owner or collaborators can close + roles := f.RolesInRepo(user) + isCollaborator := roles.IsCollaborator() + isPullAuthor := user.Did == pull.OwnerDid + isCloseAllowed := isCollaborator || isPullAuthor + if !isCloseAllowed { + log.Println("failed to close pull") + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") + return + } + + // Start a transaction + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start transaction", err) + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") + return + } + defer tx.Rollback() + + var pullsToReopen []*db.Pull + pullsToReopen = append(pullsToReopen, pull) + + // if this PR is stacked, then we want to reopen all PRs above this one on the stack + if pull.IsStacked() { + stack := r.Context().Value("stack").(db.Stack) + subStack := stack.StrictlyAbove(pull) + pullsToReopen = append(pullsToReopen, subStack...) + } + + for _, p := range pullsToReopen { + // Close the pull in the database + err = db.ReopenPull(tx, f.RepoAt, p.PullId) + if err != nil { + log.Println("failed to close pull", err) + s.pages.Notice(w, "pull-close", "Failed to close pull.") + return + } + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + log.Println("failed to commit transaction", err) + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) + return +} + +func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { + formatPatches, err := patchutil.ExtractPatches(patch) + if err != nil { + return nil, fmt.Errorf("Failed to extract patches: %v", err) + } + + // must have atleast 1 patch to begin with + if len(formatPatches) == 0 { + return nil, fmt.Errorf("No patches found in the generated format-patch.") + } + + // the stack is identified by a UUID + var stack db.Stack + parentChangeId := "" + for _, fp := range formatPatches { + // all patches must have a jj change-id + changeId, err := fp.ChangeId() + if err != nil { + return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") + } + + title := fp.Title + body := fp.Body + rkey := appview.TID() + + initialSubmission := db.PullSubmission{ + Patch: fp.Raw, + SourceRev: fp.SHA, + } + pull := db.Pull{ + Title: title, + Body: body, + TargetBranch: targetBranch, + OwnerDid: user.Did, + RepoAt: f.RepoAt, + Rkey: rkey, + Submissions: []*db.PullSubmission{ + &initialSubmission, + }, + PullSource: pullSource, + Created: time.Now(), + + StackId: stackId, + ChangeId: changeId, + ParentChangeId: parentChangeId, + } + + stack = append(stack, &pull) + + parentChangeId = changeId + } + + return stack, nil +} diff --git a/appview/reporesolver/resolver.go b/appview/reporesolver/resolver.go new file mode 100644 index 00000000..3d7b8622 --- /dev/null +++ b/appview/reporesolver/resolver.go @@ -0,0 +1,300 @@ +package reporesolver + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "path" + "strings" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/go-chi/chi/v5" + "tangled.sh/tangled.sh/core/appview" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/oauth" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" + "tangled.sh/tangled.sh/core/knotclient" + "tangled.sh/tangled.sh/core/rbac" +) + +type ResolvedRepo struct { + Knot string + OwnerId identity.Identity + RepoName string + RepoAt syntax.ATURI + Description string + CreatedAt string + Ref string + CurrentDir string + + rr *RepoResolver +} + +type RepoResolver struct { + config *appview.Config + enforcer *rbac.Enforcer + resolver *appview.Resolver + execer db.Execer +} + +func New(config *appview.Config, enforcer *rbac.Enforcer, resolver *appview.Resolver, execer db.Execer) *RepoResolver { + return &RepoResolver{config: config, enforcer: enforcer, resolver: resolver, execer: execer} +} + +func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { + repoName := chi.URLParam(r, "repo") + knot, ok := r.Context().Value("knot").(string) + if !ok { + log.Println("malformed middleware") + return nil, fmt.Errorf("malformed middleware") + } + id, ok := r.Context().Value("resolvedId").(identity.Identity) + if !ok { + log.Println("malformed middleware") + return nil, fmt.Errorf("malformed middleware") + } + + repoAt, ok := r.Context().Value("repoAt").(string) + if !ok { + log.Println("malformed middleware") + return nil, fmt.Errorf("malformed middleware") + } + + parsedRepoAt, err := syntax.ParseATURI(repoAt) + if err != nil { + log.Println("malformed repo at-uri") + return nil, fmt.Errorf("malformed middleware") + } + + ref := chi.URLParam(r, "ref") + + if ref == "" { + us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) + if err != nil { + return nil, err + } + + defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) + if err != nil { + return nil, err + } + + ref = defaultBranch.Branch + } + + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) + + // pass through values from the middleware + description, ok := r.Context().Value("repoDescription").(string) + addedAt, ok := r.Context().Value("repoAddedAt").(string) + + return &ResolvedRepo{ + Knot: knot, + OwnerId: id, + RepoName: repoName, + RepoAt: parsedRepoAt, + Description: description, + CreatedAt: addedAt, + Ref: ref, + CurrentDir: currentDir, + + rr: rr, + }, nil +} + +func (f *ResolvedRepo) OwnerDid() string { + return f.OwnerId.DID.String() +} + +func (f *ResolvedRepo) OwnerHandle() string { + return f.OwnerId.Handle.String() +} + +func (f *ResolvedRepo) OwnerSlashRepo() string { + handle := f.OwnerId.Handle + + var p string + if handle != "" && !handle.IsInvalidHandle() { + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) + } else { + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) + } + + return p +} + +func (f *ResolvedRepo) DidSlashRepo() string { + p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) + return p +} + +func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { + repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) + if err != nil { + return nil, err + } + + var collaborators []pages.Collaborator + for _, item := range repoCollaborators { + // currently only two roles: owner and member + var role string + if item[3] == "repo:owner" { + role = "owner" + } else if item[3] == "repo:collaborator" { + role = "collaborator" + } else { + continue + } + + did := item[0] + + c := pages.Collaborator{ + Did: did, + Handle: "", + Role: role, + } + collaborators = append(collaborators, c) + } + + // populate all collborators with handles + identsToResolve := make([]string, len(collaborators)) + for i, collab := range collaborators { + identsToResolve[i] = collab.Did + } + + resolvedIdents := f.rr.resolver.ResolveIdents(ctx, identsToResolve) + for i, resolved := range resolvedIdents { + if resolved != nil { + collaborators[i].Handle = resolved.Handle.String() + } + } + + return collaborators, nil +} + +// this function is a bit weird since it now returns RepoInfo from an entirely different +// package. we should refactor this or get rid of RepoInfo entirely. +func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { + isStarred := false + if user != nil { + isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) + } + + starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) + if err != nil { + log.Println("failed to get star count for ", f.RepoAt) + } + issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) + if err != nil { + log.Println("failed to get issue count for ", f.RepoAt) + } + pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) + if err != nil { + log.Println("failed to get issue count for ", f.RepoAt) + } + source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) + if errors.Is(err, sql.ErrNoRows) { + source = "" + } else if err != nil { + log.Println("failed to get repo source for ", f.RepoAt, err) + } + + var sourceRepo *db.Repo + if source != "" { + sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) + if err != nil { + log.Println("failed to get repo by at uri", err) + } + } + + var sourceHandle *identity.Identity + if sourceRepo != nil { + sourceHandle, err = f.rr.resolver.ResolveIdent(context.Background(), sourceRepo.Did) + if err != nil { + log.Println("failed to resolve source repo", err) + } + } + + knot := f.Knot + var disableFork bool + us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) + if err != nil { + log.Printf("failed to create unsigned client for %s: %v", knot, err) + } else { + result, err := us.Branches(f.OwnerDid(), f.RepoName) + if err != nil { + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) + } + + if len(result.Branches) == 0 { + disableFork = true + } + } + + repoInfo := repoinfo.RepoInfo{ + OwnerDid: f.OwnerDid(), + OwnerHandle: f.OwnerHandle(), + Name: f.RepoName, + RepoAt: f.RepoAt, + Description: f.Description, + Ref: f.Ref, + IsStarred: isStarred, + Knot: knot, + Roles: f.RolesInRepo(user), + Stats: db.RepoStats{ + StarCount: starCount, + IssueCount: issueCount, + PullCount: pullCount, + }, + DisableFork: disableFork, + CurrentDir: f.CurrentDir, + } + + if sourceRepo != nil { + repoInfo.Source = sourceRepo + repoInfo.SourceHandle = sourceHandle.Handle.String() + } + + return repoInfo +} + +func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { + if u != nil { + r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) + return repoinfo.RolesInRepo{r} + } else { + return repoinfo.RolesInRepo{} + } +} + +// extractPathAfterRef gets the actual repository path +// after the ref. for example: +// +// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ +func extractPathAfterRef(fullPath, ref string) string { + fullPath = strings.TrimPrefix(fullPath, "/") + + ref = url.PathEscape(ref) + + prefixes := []string{ + fmt.Sprintf("blob/%s/", ref), + fmt.Sprintf("tree/%s/", ref), + fmt.Sprintf("raw/%s/", ref), + } + + for _, prefix := range prefixes { + idx := strings.Index(fullPath, prefix) + if idx != -1 { + return fullPath[idx+len(prefix):] + } + } + + return "" +} diff --git a/appview/state/artifact.go b/appview/state/artifact.go index 043fc94f..5ff336a9 100644 --- a/appview/state/artifact.go +++ b/appview/state/artifact.go @@ -17,6 +17,7 @@ import ( "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/types" ) @@ -25,7 +26,7 @@ import ( func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) tagParam := chi.URLParam(r, "tag") - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") @@ -124,7 +125,7 @@ func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Artifact: artifact, }) } @@ -133,7 +134,7 @@ func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) { tagParam := chi.URLParam(r, "tag") filename := chi.URLParam(r, "file") - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -184,7 +185,7 @@ func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) tagParam := chi.URLParam(r, "tag") filename := chi.URLParam(r, "file") - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -258,7 +259,7 @@ func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) { w.Write([]byte{}) } -func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) { +func (s *State) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { tagParam, err := url.QueryUnescape(tagParam) if err != nil { return nil, err diff --git a/appview/state/middleware.go b/appview/state/middleware.go index e78cb0a3..2a795676 100644 --- a/appview/state/middleware.go +++ b/appview/state/middleware.go @@ -62,7 +62,7 @@ func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middlewa http.Error(w, "Forbiden", http.StatusUnauthorized) return } - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { http.Error(w, "malformed url", http.StatusBadRequest) return @@ -149,7 +149,7 @@ func ResolveRepo(s *State) middleware.Middleware { func ResolvePull(s *State) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) http.Error(w, "invalid repo url", http.StatusNotFound) @@ -198,7 +198,7 @@ func ResolvePull(s *State) middleware.Middleware { func GoImport(s *State) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) http.Error(w, "invalid repo url", http.StatusNotFound) diff --git a/appview/state/pull.go b/appview/state/pull.go index eaf7778c..99a5dac1 100644 --- a/appview/state/pull.go +++ b/appview/state/pull.go @@ -18,6 +18,7 @@ import ( "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/patchutil" "tangled.sh/tangled.sh/core/types" @@ -36,7 +37,7 @@ func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -71,7 +72,7 @@ func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { s.pages.PullActionsFragment(w, pages.PullActionsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pull: pull, RoundNumber: roundNumber, MergeCheck: mergeCheckResponse, @@ -84,7 +85,7 @@ func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -136,7 +137,7 @@ func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Pull: pull, Stack: stack, @@ -146,7 +147,7 @@ func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { }) } -func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { +func (s *State) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { if pull.State == db.PullMerged { return types.MergeCheckResponse{} } @@ -216,7 +217,7 @@ func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) return mergeCheckResponse } -func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { +func (s *State) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { return pages.Unknown } @@ -271,7 +272,7 @@ func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stac func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -311,7 +312,7 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ LoggedInUser: user, DidHandleMap: didHandleMap, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pull: pull, Stack: stack, Round: roundIdInt, @@ -324,7 +325,7 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -380,7 +381,7 @@ func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ LoggedInUser: s.oauth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pull: pull, Round: roundIdInt, DidHandleMap: didHandleMap, @@ -432,7 +433,7 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { state = db.PullMerged } - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -480,7 +481,7 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { s.pages.RepoPulls(w, pages.RepoPullsParams{ LoggedInUser: s.oauth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pulls: pulls, DidHandleMap: didHandleMap, FilteringBy: state, @@ -490,7 +491,7 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -515,7 +516,7 @@ func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { case http.MethodGet: s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pull: pull, RoundNumber: roundNumber, }) @@ -613,7 +614,7 @@ func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -642,7 +643,7 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { s.pages.RepoNewPull(w, pages.RepoNewPullParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Branches: result.Branches, Strategy: strategy, SourceBranch: sourceBranch, @@ -665,7 +666,7 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { } // Determine PR type based on input parameters - isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() + isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" isForkBased := fromFork != "" && sourceBranch != "" isPatchBased := patch != "" && !isBranchBased && !isForkBased @@ -736,7 +737,7 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { func (s *State) handleBranchBasedPull( w http.ResponseWriter, r *http.Request, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, title, body, @@ -777,7 +778,7 @@ func (s *State) handleBranchBasedPull( s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) } -func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { +func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { if !patchutil.IsPatchValid(patch) { s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return @@ -786,7 +787,7 @@ func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f * s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) } -func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { +func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) if errors.Is(err, sql.ErrNoRows) { s.pages.Notice(w, "pull", "No such fork.") @@ -869,7 +870,7 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F func (s *State) createPullRequest( w http.ResponseWriter, r *http.Request, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch string, patch string, @@ -998,7 +999,7 @@ func (s *State) createPullRequest( func (s *State) createStackedPulLRequest( w http.ResponseWriter, r *http.Request, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch string, patch string, @@ -1097,7 +1098,7 @@ func (s *State) createStackedPulLRequest( } func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { - _, err := s.fullyResolvedRepo(r) + _, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1123,20 +1124,20 @@ func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return } s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), }) } func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1169,14 +1170,14 @@ func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) } s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Branches: withoutDefault, }) } func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1189,7 +1190,7 @@ func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { } s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Forks: forks, Selected: r.URL.Query().Get("fork"), }) @@ -1198,7 +1199,7 @@ func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1245,7 +1246,7 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ }) s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), SourceBranches: sourceBranches, TargetBranches: targetResult.Branches, }) @@ -1253,7 +1254,7 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1269,7 +1270,7 @@ func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Pull: pull, }) return @@ -1297,7 +1298,7 @@ func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { return } - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1324,7 +1325,7 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { return } - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1336,7 +1337,7 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { return } - if !f.RepoInfo(s, user).Roles.IsPushAllowed() { + if !f.RepoInfo(user).Roles.IsPushAllowed() { log.Println("unauthorized user") w.WriteHeader(http.StatusUnauthorized) return @@ -1372,7 +1373,7 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { return } - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1455,7 +1456,7 @@ func validateResubmittedPatch(pull *db.Pull, patch string) error { func (s *State) resubmitPullHelper( w http.ResponseWriter, r *http.Request, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, pull *db.Pull, patch string, @@ -1557,7 +1558,7 @@ func (s *State) resubmitPullHelper( func (s *State) resubmitStackedPullHelper( w http.ResponseWriter, r *http.Request, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, pull *db.Pull, patch string, @@ -1799,7 +1800,7 @@ func (s *State) resubmitStackedPullHelper( } func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to resolve repo:", err) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") @@ -1904,7 +1905,7 @@ func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("malformed middleware") return @@ -1918,7 +1919,7 @@ func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { } // auth filter: only owner or collaborators can close - roles := RolesInRepo(s, user, f) + roles := f.RolesInRepo(user) isCollaborator := roles.IsCollaborator() isPullAuthor := user.Did == pull.OwnerDid isCloseAllowed := isCollaborator || isPullAuthor @@ -1971,7 +1972,7 @@ func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to resolve repo", err) s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") @@ -1986,7 +1987,7 @@ func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { } // auth filter: only owner or collaborators can close - roles := RolesInRepo(s, user, f) + roles := f.RolesInRepo(user) isCollaborator := roles.IsCollaborator() isPullAuthor := user.Did == pull.OwnerDid isCloseAllowed := isCollaborator || isPullAuthor @@ -2036,7 +2037,7 @@ func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { return } -func newStack(f *FullyResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { +func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { formatPatches, err := patchutil.ExtractPatches(patch) if err != nil { return nil, fmt.Errorf("Failed to extract patches: %v", err) diff --git a/appview/state/repo.go b/appview/state/repo.go index 1a138295..db602027 100644 --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -1,7 +1,6 @@ package state import ( - "context" "database/sql" "encoding/json" "errors" @@ -25,13 +24,12 @@ import ( "tangled.sh/tangled.sh/core/appview/pages/markup" "tangled.sh/tangled.sh/core/appview/pages/repoinfo" "tangled.sh/tangled.sh/core/appview/pagination" + "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/patchutil" "tangled.sh/tangled.sh/core/types" "github.com/bluesky-social/indigo/atproto/data" - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" @@ -43,7 +41,7 @@ import ( func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { ref := chi.URLParam(r, "ref") - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) return @@ -110,7 +108,7 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { emails := uniqueEmails(commitsTrunc) user := s.oauth.GetUser(r) - repoInfo := f.RepoInfo(s, user) + repoInfo := f.RepoInfo(user) secret, err := db.GetRegistrationKey(s.db, f.Knot) if err != nil { @@ -157,7 +155,7 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { func getForkInfo( repoInfo repoinfo.RepoInfo, s *State, - f *FullyResolvedRepo, + f *reporesolver.ResolvedRepo, user *oauth.User, signedClient *knotclient.SignedClient, ) (*types.ForkInfo, error) { @@ -219,7 +217,7 @@ func getForkInfo( } func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) return @@ -266,7 +264,7 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { s.pages.RepoLog(w, pages.RepoLogParams{ LoggedInUser: user, TagMap: tagMap, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoLogResponse: *repolog, EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), }) @@ -274,7 +272,7 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) w.WriteHeader(http.StatusBadRequest) @@ -283,13 +281,13 @@ func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), }) return } func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) w.WriteHeader(http.StatusBadRequest) @@ -309,7 +307,7 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), }) return case http.MethodPut: @@ -362,7 +360,7 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { return } - newRepoInfo := f.RepoInfo(s, user) + newRepoInfo := f.RepoInfo(user) newRepoInfo.Description = newDescription s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ @@ -373,7 +371,7 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) return @@ -411,7 +409,7 @@ func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.RepoCommit(w, pages.RepoCommitParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoCommitResponse: result, EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), }) @@ -419,7 +417,7 @@ func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to fully resolve repo", err) return @@ -475,14 +473,14 @@ func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { BreadCrumbs: breadcrumbs, BaseTreeLink: baseTreeLink, BaseBlobLink: baseBlobLink, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoTreeResponse: result, }) return } func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -531,7 +529,7 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.RepoTags(w, pages.RepoTagsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoTagsResponse: *result, ArtifactMap: artifactMap, DanglingArtifacts: danglingArtifacts, @@ -540,7 +538,7 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -578,14 +576,14 @@ func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.RepoBranches(w, pages.RepoBranchesParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoBranchesResponse: *result, }) return } func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -635,7 +633,7 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.RepoBlob(w, pages.RepoBlobParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), RepoBlobResponse: result, BreadCrumbs: breadcrumbs, ShowRendered: showRendered, @@ -645,7 +643,7 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -689,7 +687,7 @@ func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { } func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -780,7 +778,7 @@ func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -888,7 +886,7 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { } func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -927,7 +925,7 @@ func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -937,7 +935,7 @@ func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { case http.MethodGet: // for now, this is just pubkeys user := s.oauth.GetUser(r) - repoCollaborators, err := f.Collaborators(r.Context(), s) + repoCollaborators, err := f.Collaborators(r.Context()) if err != nil { log.Println("failed to get collaborators", err) } @@ -964,7 +962,7 @@ func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { s.pages.RepoSettings(w, pages.RepoSettingsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Collaborators: repoCollaborators, IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, Branches: result.Branches, @@ -972,174 +970,9 @@ func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { } } -type FullyResolvedRepo struct { - Knot string - OwnerId identity.Identity - RepoName string - RepoAt syntax.ATURI - Description string - CreatedAt string - Ref string - CurrentDir string -} - -func (f *FullyResolvedRepo) OwnerDid() string { - return f.OwnerId.DID.String() -} - -func (f *FullyResolvedRepo) OwnerHandle() string { - return f.OwnerId.Handle.String() -} - -func (f *FullyResolvedRepo) OwnerSlashRepo() string { - handle := f.OwnerId.Handle - - var p string - if handle != "" && !handle.IsInvalidHandle() { - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) - } else { - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) - } - - return p -} - -func (f *FullyResolvedRepo) DidSlashRepo() string { - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) - return p -} - -func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) - if err != nil { - return nil, err - } - - var collaborators []pages.Collaborator - for _, item := range repoCollaborators { - // currently only two roles: owner and member - var role string - if item[3] == "repo:owner" { - role = "owner" - } else if item[3] == "repo:collaborator" { - role = "collaborator" - } else { - continue - } - - did := item[0] - - c := pages.Collaborator{ - Did: did, - Handle: "", - Role: role, - } - collaborators = append(collaborators, c) - } - - // populate all collborators with handles - identsToResolve := make([]string, len(collaborators)) - for i, collab := range collaborators { - identsToResolve[i] = collab.Did - } - - resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) - for i, resolved := range resolvedIdents { - if resolved != nil { - collaborators[i].Handle = resolved.Handle.String() - } - } - - return collaborators, nil -} - -func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { - isStarred := false - if u != nil { - isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) - } - - starCount, err := db.GetStarCount(s.db, f.RepoAt) - if err != nil { - log.Println("failed to get star count for ", f.RepoAt) - } - issueCount, err := db.GetIssueCount(s.db, f.RepoAt) - if err != nil { - log.Println("failed to get issue count for ", f.RepoAt) - } - pullCount, err := db.GetPullCount(s.db, f.RepoAt) - if err != nil { - log.Println("failed to get issue count for ", f.RepoAt) - } - source, err := db.GetRepoSource(s.db, f.RepoAt) - if errors.Is(err, sql.ErrNoRows) { - source = "" - } else if err != nil { - log.Println("failed to get repo source for ", f.RepoAt, err) - } - - var sourceRepo *db.Repo - if source != "" { - sourceRepo, err = db.GetRepoByAtUri(s.db, source) - if err != nil { - log.Println("failed to get repo by at uri", err) - } - } - - var sourceHandle *identity.Identity - if sourceRepo != nil { - sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) - if err != nil { - log.Println("failed to resolve source repo", err) - } - } - - knot := f.Knot - var disableFork bool - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) - if err != nil { - log.Printf("failed to create unsigned client for %s: %v", knot, err) - } else { - result, err := us.Branches(f.OwnerDid(), f.RepoName) - if err != nil { - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) - } - - if len(result.Branches) == 0 { - disableFork = true - } - } - - repoInfo := repoinfo.RepoInfo{ - OwnerDid: f.OwnerDid(), - OwnerHandle: f.OwnerHandle(), - Name: f.RepoName, - RepoAt: f.RepoAt, - Description: f.Description, - Ref: f.Ref, - IsStarred: isStarred, - Knot: knot, - Roles: RolesInRepo(s, u, f), - Stats: db.RepoStats{ - StarCount: starCount, - IssueCount: issueCount, - PullCount: pullCount, - }, - DisableFork: disableFork, - CurrentDir: f.CurrentDir, - } - - if sourceRepo != nil { - repoInfo.Source = sourceRepo - repoInfo.SourceHandle = sourceHandle.Handle.String() - } - - return repoInfo -} - func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1181,7 +1014,7 @@ func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Issue: *issue, Comments: comments, @@ -1193,7 +1026,7 @@ func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1214,7 +1047,7 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { return } - collaborators, err := f.Collaborators(r.Context(), s) + collaborators, err := f.Collaborators(r.Context()) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) } @@ -1269,7 +1102,7 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1290,7 +1123,7 @@ func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { return } - collaborators, err := f.Collaborators(r.Context(), s) + collaborators, err := f.Collaborators(r.Context()) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) } @@ -1317,7 +1150,7 @@ func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1401,7 +1234,7 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1451,7 +1284,7 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, @@ -1460,7 +1293,7 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1504,7 +1337,7 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { case http.MethodGet: s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Issue: issue, Comment: comment, }) @@ -1577,7 +1410,7 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { // return new comment body with htmx s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, @@ -1590,7 +1423,7 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1672,7 +1505,7 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { // htmx fragment of comment after deletion s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, @@ -1700,7 +1533,7 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { } user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1729,7 +1562,7 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { s.pages.RepoIssues(w, pages.RepoIssuesParams{ LoggedInUser: s.oauth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), Issues: issues, DidHandleMap: didHandleMap, FilteringByOpen: isOpen, @@ -1741,7 +1574,7 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -1751,7 +1584,7 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { case http.MethodGet: s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), }) case http.MethodPost: title := r.FormValue("title") @@ -1839,7 +1672,7 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Printf("failed to resolve source repo: %v", err) return @@ -1881,7 +1714,7 @@ func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Printf("failed to resolve source repo: %v", err) return @@ -1899,7 +1732,7 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { s.pages.ForkRepo(w, pages.ForkRepoParams{ LoggedInUser: user, Knots: knots, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(user), }) case http.MethodPost: @@ -2059,7 +1892,7 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { func (s *State) RepoCompareNew(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -2110,7 +1943,7 @@ func (s *State) RepoCompareNew(w http.ResponseWriter, r *http.Request) { return } - repoinfo := f.RepoInfo(s, user) + repoinfo := f.RepoInfo(user) s.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ LoggedInUser: user, @@ -2124,7 +1957,7 @@ func (s *State) RepoCompareNew(w http.ResponseWriter, r *http.Request) { func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.repoResolver.Resolve(r) if err != nil { log.Println("failed to get repo and knot", err) return @@ -2179,7 +2012,7 @@ func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) { } diff := patchutil.AsNiceDiff(formatPatch.Patch, base) - repoinfo := f.RepoInfo(s, user) + repoinfo := f.RepoInfo(user) s.pages.RepoCompare(w, pages.RepoCompareParams{ LoggedInUser: user, diff --git a/appview/state/repo_util.go b/appview/state/repo_util.go index f5302c9a..394760bb 100644 --- a/appview/state/repo_util.go +++ b/appview/state/repo_util.go @@ -6,114 +6,11 @@ import ( "fmt" "log" "math/big" - "net/http" - "net/url" - "path" - "strings" - - "github.com/bluesky-social/indigo/atproto/identity" - "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing/object" "tangled.sh/tangled.sh/core/appview/db" - "tangled.sh/tangled.sh/core/appview/oauth" - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" - "tangled.sh/tangled.sh/core/knotclient" ) -func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { - repoName := chi.URLParam(r, "repo") - knot, ok := r.Context().Value("knot").(string) - if !ok { - log.Println("malformed middleware") - return nil, fmt.Errorf("malformed middleware") - } - id, ok := r.Context().Value("resolvedId").(identity.Identity) - if !ok { - log.Println("malformed middleware") - return nil, fmt.Errorf("malformed middleware") - } - - repoAt, ok := r.Context().Value("repoAt").(string) - if !ok { - log.Println("malformed middleware") - return nil, fmt.Errorf("malformed middleware") - } - - parsedRepoAt, err := syntax.ParseATURI(repoAt) - if err != nil { - log.Println("malformed repo at-uri") - return nil, fmt.Errorf("malformed middleware") - } - - ref := chi.URLParam(r, "ref") - - if ref == "" { - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) - if err != nil { - return nil, err - } - - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) - if err != nil { - return nil, err - } - - ref = defaultBranch.Branch - } - - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) - - // pass through values from the middleware - description, ok := r.Context().Value("repoDescription").(string) - addedAt, ok := r.Context().Value("repoAddedAt").(string) - - return &FullyResolvedRepo{ - Knot: knot, - OwnerId: id, - RepoName: repoName, - RepoAt: parsedRepoAt, - Description: description, - CreatedAt: addedAt, - Ref: ref, - CurrentDir: currentDir, - }, nil -} - -func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo { - if u != nil { - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) - return repoinfo.RolesInRepo{r} - } else { - return repoinfo.RolesInRepo{} - } -} - -// extractPathAfterRef gets the actual repository path -// after the ref. for example: -// -// /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ -func extractPathAfterRef(fullPath, ref string) string { - fullPath = strings.TrimPrefix(fullPath, "/") - - ref = url.PathEscape(ref) - - prefixes := []string{ - fmt.Sprintf("blob/%s/", ref), - fmt.Sprintf("tree/%s/", ref), - fmt.Sprintf("raw/%s/", ref), - } - - for _, prefix := range prefixes { - idx := strings.Index(fullPath, prefix) - if idx != -1 { - return fullPath[idx+len(prefix):] - } - } - - return "" -} - func uniqueEmails(commits []*object.Commit) []string { emails := make(map[string]struct{}) for _, commit := range commits { diff --git a/appview/state/state.go b/appview/state/state.go index b69164fd..bb5e2706 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -23,21 +23,23 @@ import ( "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/jetstream" "tangled.sh/tangled.sh/core/knotclient" "tangled.sh/tangled.sh/core/rbac" ) type State struct { - db *db.DB - oauth *oauth.OAuth - enforcer *rbac.Enforcer - tidClock syntax.TIDClock - pages *pages.Pages - resolver *appview.Resolver - posthog posthog.Client - jc *jetstream.JetstreamClient - config *appview.Config + db *db.DB + oauth *oauth.OAuth + enforcer *rbac.Enforcer + tidClock syntax.TIDClock + pages *pages.Pages + resolver *appview.Resolver + posthog posthog.Client + jc *jetstream.JetstreamClient + config *appview.Config + repoResolver *reporesolver.RepoResolver } func Make(config *appview.Config) (*State, error) { @@ -68,6 +70,8 @@ func Make(config *appview.Config) (*State, error) { return nil, fmt.Errorf("failed to create posthog client: %w", err) } + repoResolver := reporesolver.New(config, enforcer, resolver, d) + wrapper := db.DbWrapper{d} jc, err := jetstream.NewJetstreamClient( config.Jetstream.Endpoint, @@ -102,6 +106,7 @@ func Make(config *appview.Config) (*State, error) { posthog, jc, config, + repoResolver, } return state, nil