diff --git a/appview/issues/issues.go b/appview/issues/issues.go new file mode 100644 index 00000000..fb227d3d --- /dev/null +++ b/appview/issues/issues.go @@ -0,0 +1,757 @@ +package issues + +import ( + "fmt" + "log" + mathrand "math/rand/v2" + "net/http" + "slices" + "strconv" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/data" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" + "github.com/posthog/posthog-go" + + "tangled.sh/tangled.sh/core/api/tangled" + "tangled.sh/tangled.sh/core/appview" + "tangled.sh/tangled.sh/core/appview/config" + "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/idresolver" + "tangled.sh/tangled.sh/core/appview/oauth" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/pagination" + "tangled.sh/tangled.sh/core/appview/reporesolver" +) + +type Issues struct { + oauth *oauth.OAuth + repoResolver *reporesolver.RepoResolver + pages *pages.Pages + idResolver *idresolver.Resolver + db *db.DB + config *config.Config + posthog posthog.Client +} + +func New( + oauth *oauth.OAuth, + repoResolver *reporesolver.RepoResolver, + pages *pages.Pages, + idResolver *idresolver.Resolver, + db *db.DB, + config *config.Config, + posthog posthog.Client, +) *Issues { + return &Issues{ + oauth: oauth, + repoResolver: repoResolver, + pages: pages, + idResolver: idResolver, + db: db, + config: config, + posthog: posthog, + } +} + +func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue and comments", err) + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) + if err != nil { + log.Println("failed to resolve issue owner", err) + } + + identsToResolve := make([]string, len(comments)) + for i, comment := range comments { + identsToResolve[i] = comment.OwnerDid + } + resolvedIds := rp.idResolver.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() + } + } + + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Issue: *issue, + Comments: comments, + + IssueOwnerHandle: issueOwnerIdent.Handle.String(), + DidHandleMap: didHandleMap, + }) + +} + +func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") + return + } + + collaborators, err := f.Collaborators(r.Context()) + if err != nil { + log.Println("failed to fetch repo collaborators: %w", err) + } + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { + return user.Did == collab.Did + }) + isIssueOwner := user.Did == issue.OwnerDid + + // TODO: make this more granular + if isIssueOwner || isCollaborator { + + closed := tangled.RepoIssueStateClosed + + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + return + } + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoIssueStateNSID, + Repo: user.Did, + Rkey: appview.TID(), + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoIssueState{ + Issue: issue.IssueAt, + State: closed, + }, + }, + }) + + if err != nil { + log.Println("failed to update issue state", err) + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") + return + } + + err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to close issue", err) + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") + return + } + + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) + return + } else { + log.Println("user is not permitted to close issue") + http.Error(w, "for biden", http.StatusUnauthorized) + return + } +} + +func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") + return + } + + collaborators, err := f.Collaborators(r.Context()) + if err != nil { + log.Println("failed to fetch repo collaborators: %w", err) + } + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { + return user.Did == collab.Did + }) + isIssueOwner := user.Did == issue.OwnerDid + + if isCollaborator || isIssueOwner { + err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to reopen issue", err) + rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") + return + } + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) + return + } else { + log.Println("user is not the owner of the repo") + http.Error(w, "forbidden", http.StatusUnauthorized) + return + } +} + +func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + switch r.Method { + case http.MethodPost: + body := r.FormValue("body") + if body == "" { + rp.pages.Notice(w, "issue", "Body is required") + return + } + + commentId := mathrand.IntN(1000000) + rkey := appview.TID() + + err := db.NewIssueComment(rp.db, &db.Comment{ + OwnerDid: user.Did, + RepoAt: f.RepoAt, + Issue: issueIdInt, + CommentId: commentId, + Body: body, + Rkey: rkey, + }) + if err != nil { + log.Println("failed to create comment", err) + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") + return + } + + createdAt := time.Now().Format(time.RFC3339) + commentIdInt64 := int64(commentId) + ownerDid := user.Did + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue at", err) + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") + return + } + + atUri := f.RepoAt.String() + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") + return + } + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoIssueCommentNSID, + Repo: user.Did, + Rkey: rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoIssueComment{ + Repo: &atUri, + Issue: issueAt, + CommentId: &commentIdInt64, + Owner: &ownerDid, + Body: body, + CreatedAt: createdAt, + }, + }, + }) + if err != nil { + log.Println("failed to create comment", err) + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") + return + } + + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) + return + } +} + +func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + commentId := chi.URLParam(r, "comment_id") + commentIdInt, err := strconv.Atoi(commentId) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + return + } + + identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) + if err != nil { + log.Println("failed to resolve did") + return + } + + didHandleMap := make(map[string]string) + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + DidHandleMap: didHandleMap, + Issue: issue, + Comment: comment, + }) +} + +func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + commentId := chi.URLParam(r, "comment_id") + commentIdInt, err := strconv.Atoi(commentId) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + return + } + + if comment.OwnerDid != user.Did { + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) + return + } + + switch r.Method { + case http.MethodGet: + rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + Issue: issue, + Comment: comment, + }) + case http.MethodPost: + // extract form value + newBody := r.FormValue("body") + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") + return + } + rkey := comment.Rkey + + // optimistic update + edited := time.Now() + err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) + if err != nil { + log.Println("failed to perferom update-description query", err) + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") + return + } + + // rkey is optional, it was introduced later + if comment.Rkey != "" { + // update the record on pds + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) + if err != nil { + // failed to get record + log.Println(err, rkey) + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") + return + } + value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json + record, _ := data.UnmarshalJSON(value) + + repoAt := record["repo"].(string) + issueAt := record["issue"].(string) + createdAt := record["createdAt"].(string) + commentIdInt64 := int64(commentIdInt) + + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoIssueCommentNSID, + Repo: user.Did, + Rkey: rkey, + SwapRecord: ex.Cid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoIssueComment{ + Repo: &repoAt, + Issue: issueAt, + CommentId: &commentIdInt64, + Owner: &comment.OwnerDid, + Body: newBody, + CreatedAt: createdAt, + }, + }, + }) + if err != nil { + log.Println(err) + } + } + + // optimistic update for htmx + didHandleMap := map[string]string{ + user.Did: user.Handle, + } + comment.Body = newBody + comment.Edited = &edited + + // return new comment body with htmx + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + DidHandleMap: didHandleMap, + Issue: issue, + Comment: comment, + }) + return + + } + +} + +func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issueId := chi.URLParam(r, "issue") + issueIdInt, err := strconv.Atoi(issueId) + if err != nil { + http.Error(w, "bad issue id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) + if err != nil { + log.Println("failed to get issue", err) + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") + return + } + + commentId := chi.URLParam(r, "comment_id") + commentIdInt, err := strconv.Atoi(commentId) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + log.Println("failed to parse issue id", err) + return + } + + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + http.Error(w, "bad comment id", http.StatusBadRequest) + return + } + + if comment.OwnerDid != user.Did { + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) + return + } + + if comment.Deleted != nil { + http.Error(w, "comment already deleted", http.StatusBadRequest) + return + } + + // optimistic deletion + deleted := time.Now() + err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) + if err != nil { + log.Println("failed to delete comment") + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") + return + } + + // delete from pds + if comment.Rkey != "" { + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") + return + } + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + Collection: tangled.GraphFollowNSID, + Repo: user.Did, + Rkey: comment.Rkey, + }) + if err != nil { + log.Println(err) + } + } + + // optimistic update for htmx + didHandleMap := map[string]string{ + user.Did: user.Handle, + } + comment.Body = "" + comment.Deleted = &deleted + + // htmx fragment of comment after deletion + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + DidHandleMap: didHandleMap, + Issue: issue, + Comment: comment, + }) + return +} + +func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + state := params.Get("state") + isOpen := true + switch state { + case "open": + isOpen = true + case "closed": + isOpen = false + default: + isOpen = true + } + + page, ok := r.Context().Value("page").(pagination.Page) + if !ok { + log.Println("failed to get page") + page = pagination.FirstPage() + } + + user := rp.oauth.GetUser(r) + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) + if err != nil { + log.Println("failed to get issues", err) + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") + return + } + + identsToResolve := make([]string, len(issues)) + for i, issue := range issues { + identsToResolve[i] = issue.OwnerDid + } + resolvedIds := rp.idResolver.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() + } + } + + rp.pages.RepoIssues(w, pages.RepoIssuesParams{ + LoggedInUser: rp.oauth.GetUser(r), + RepoInfo: f.RepoInfo(user), + Issues: issues, + DidHandleMap: didHandleMap, + FilteringByOpen: isOpen, + Page: page, + }) + return +} + +func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { + user := rp.oauth.GetUser(r) + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + switch r.Method { + case http.MethodGet: + rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(user), + }) + case http.MethodPost: + title := r.FormValue("title") + body := r.FormValue("body") + + if title == "" || body == "" { + rp.pages.Notice(w, "issues", "Title and body are required") + return + } + + tx, err := rp.db.BeginTx(r.Context(), nil) + if err != nil { + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") + return + } + + err = db.NewIssue(tx, &db.Issue{ + RepoAt: f.RepoAt, + Title: title, + Body: body, + OwnerDid: user.Did, + }) + if err != nil { + log.Println("failed to create issue", err) + rp.pages.Notice(w, "issues", "Failed to create issue.") + return + } + + issueId, err := db.GetIssueId(rp.db, f.RepoAt) + if err != nil { + log.Println("failed to get issue id", err) + rp.pages.Notice(w, "issues", "Failed to create issue.") + return + } + + client, err := rp.oauth.AuthorizedClient(r) + if err != nil { + log.Println("failed to get authorized client", err) + rp.pages.Notice(w, "issues", "Failed to create issue.") + return + } + atUri := f.RepoAt.String() + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoIssueNSID, + Repo: user.Did, + Rkey: appview.TID(), + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoIssue{ + Repo: atUri, + Title: title, + Body: &body, + Owner: user.Did, + IssueId: int64(issueId), + }, + }, + }) + if err != nil { + log.Println("failed to create issue", err) + rp.pages.Notice(w, "issues", "Failed to create issue.") + return + } + + err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) + if err != nil { + log.Println("failed to set issue at", err) + rp.pages.Notice(w, "issues", "Failed to create issue.") + return + } + + if !rp.config.Core.Dev { + err = rp.posthog.Enqueue(posthog.Capture{ + DistinctId: user.Did, + Event: "new_issue", + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, + }) + if err != nil { + log.Println("failed to enqueue posthog event:", err) + } + } + + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) + return + } +} diff --git a/appview/issues/router.go b/appview/issues/router.go new file mode 100644 index 00000000..f2420da7 --- /dev/null +++ b/appview/issues/router.go @@ -0,0 +1,34 @@ +package issues + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "tangled.sh/tangled.sh/core/appview/middleware" +) + +func (i *Issues) Router(mw *middleware.Middleware) http.Handler { + r := chi.NewRouter() + + r.Route("/", func(r chi.Router) { + r.With(middleware.Paginate).Get("/", i.RepoIssues) + r.Get("/{issue}", i.RepoSingleIssue) + + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(i.oauth)) + r.Get("/new", i.NewIssue) + r.Post("/new", i.NewIssue) + r.Post("/{issue}/comment", i.NewIssueComment) + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { + r.Get("/", i.IssueComment) + r.Delete("/", i.DeleteIssueComment) + r.Get("/edit", i.EditIssueComment) + r.Post("/edit", i.EditIssueComment) + }) + r.Post("/{issue}/close", i.CloseIssue) + r.Post("/{issue}/reopen", i.ReopenIssue) + }) + }) + + return r +} diff --git a/appview/repo/repo.go b/appview/repo/repo.go index 1e59af44..5810f515 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "log" - mathrand "math/rand/v2" "net/http" "path" "slices" @@ -25,14 +24,12 @@ import ( "tangled.sh/tangled.sh/core/appview/pages" "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/rbac" "tangled.sh/tangled.sh/core/types" - "github.com/bluesky-social/indigo/atproto/data" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" @@ -1005,706 +1002,6 @@ func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { } } -func (rp *Repo) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue and comments", err) - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") - return - } - - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) - if err != nil { - log.Println("failed to resolve issue owner", err) - } - - identsToResolve := make([]string, len(comments)) - for i, comment := range comments { - identsToResolve[i] = comment.OwnerDid - } - resolvedIds := rp.idResolver.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() - } - } - - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Issue: *issue, - Comments: comments, - - IssueOwnerHandle: issueOwnerIdent.Handle.String(), - DidHandleMap: didHandleMap, - }) - -} - -func (rp *Repo) CloseIssue(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue", err) - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") - return - } - - collaborators, err := f.Collaborators(r.Context()) - if err != nil { - log.Println("failed to fetch repo collaborators: %w", err) - } - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { - return user.Did == collab.Did - }) - isIssueOwner := user.Did == issue.OwnerDid - - // TODO: make this more granular - if isIssueOwner || isCollaborator { - - closed := tangled.RepoIssueStateClosed - - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - log.Println("failed to get authorized client", err) - return - } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueStateNSID, - Repo: user.Did, - Rkey: appview.TID(), - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoIssueState{ - Issue: issue.IssueAt, - State: closed, - }, - }, - }) - - if err != nil { - log.Println("failed to update issue state", err) - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") - return - } - - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to close issue", err) - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") - return - } - - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) - return - } else { - log.Println("user is not permitted to close issue") - http.Error(w, "for biden", http.StatusUnauthorized) - return - } -} - -func (rp *Repo) ReopenIssue(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue", err) - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") - return - } - - collaborators, err := f.Collaborators(r.Context()) - if err != nil { - log.Println("failed to fetch repo collaborators: %w", err) - } - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { - return user.Did == collab.Did - }) - isIssueOwner := user.Did == issue.OwnerDid - - if isCollaborator || isIssueOwner { - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to reopen issue", err) - rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") - return - } - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) - return - } else { - log.Println("user is not the owner of the repo") - http.Error(w, "forbidden", http.StatusUnauthorized) - return - } -} - -func (rp *Repo) NewIssueComment(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - switch r.Method { - case http.MethodPost: - body := r.FormValue("body") - if body == "" { - rp.pages.Notice(w, "issue", "Body is required") - return - } - - commentId := mathrand.IntN(1000000) - rkey := appview.TID() - - err := db.NewIssueComment(rp.db, &db.Comment{ - OwnerDid: user.Did, - RepoAt: f.RepoAt, - Issue: issueIdInt, - CommentId: commentId, - Body: body, - Rkey: rkey, - }) - if err != nil { - log.Println("failed to create comment", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - createdAt := time.Now().Format(time.RFC3339) - commentIdInt64 := int64(commentId) - ownerDid := user.Did - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue at", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - atUri := f.RepoAt.String() - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - log.Println("failed to get authorized client", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Did, - Rkey: rkey, - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoIssueComment{ - Repo: &atUri, - Issue: issueAt, - CommentId: &commentIdInt64, - Owner: &ownerDid, - Body: body, - CreatedAt: createdAt, - }, - }, - }) - if err != nil { - log.Println("failed to create comment", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) - return - } -} - -func (rp *Repo) IssueComment(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - commentId := chi.URLParam(r, "comment_id") - commentIdInt, err := strconv.Atoi(commentId) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue", err) - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") - return - } - - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - return - } - - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) - if err != nil { - log.Println("failed to resolve did") - return - } - - didHandleMap := make(map[string]string) - if !identity.Handle.IsInvalidHandle() { - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) - } else { - didHandleMap[identity.DID.String()] = identity.DID.String() - } - - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - DidHandleMap: didHandleMap, - Issue: issue, - Comment: comment, - }) -} - -func (rp *Repo) EditIssueComment(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - commentId := chi.URLParam(r, "comment_id") - commentIdInt, err := strconv.Atoi(commentId) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue", err) - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") - return - } - - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - return - } - - if comment.OwnerDid != user.Did { - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) - return - } - - switch r.Method { - case http.MethodGet: - rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - Issue: issue, - Comment: comment, - }) - case http.MethodPost: - // extract form value - newBody := r.FormValue("body") - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - log.Println("failed to get authorized client", err) - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") - return - } - rkey := comment.Rkey - - // optimistic update - edited := time.Now() - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) - if err != nil { - log.Println("failed to perferom update-description query", err) - rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") - return - } - - // rkey is optional, it was introduced later - if comment.Rkey != "" { - // update the record on pds - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) - if err != nil { - // failed to get record - log.Println(err, rkey) - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") - return - } - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json - record, _ := data.UnmarshalJSON(value) - - repoAt := record["repo"].(string) - issueAt := record["issue"].(string) - createdAt := record["createdAt"].(string) - commentIdInt64 := int64(commentIdInt) - - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueCommentNSID, - Repo: user.Did, - Rkey: rkey, - SwapRecord: ex.Cid, - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoIssueComment{ - Repo: &repoAt, - Issue: issueAt, - CommentId: &commentIdInt64, - Owner: &comment.OwnerDid, - Body: newBody, - CreatedAt: createdAt, - }, - }, - }) - if err != nil { - log.Println(err) - } - } - - // optimistic update for htmx - didHandleMap := map[string]string{ - user.Did: user.Handle, - } - comment.Body = newBody - comment.Edited = &edited - - // return new comment body with htmx - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - DidHandleMap: didHandleMap, - Issue: issue, - Comment: comment, - }) - return - - } - -} - -func (rp *Repo) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issueId := chi.URLParam(r, "issue") - issueIdInt, err := strconv.Atoi(issueId) - if err != nil { - http.Error(w, "bad issue id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) - if err != nil { - log.Println("failed to get issue", err) - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") - return - } - - commentId := chi.URLParam(r, "comment_id") - commentIdInt, err := strconv.Atoi(commentId) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - log.Println("failed to parse issue id", err) - return - } - - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) - if err != nil { - http.Error(w, "bad comment id", http.StatusBadRequest) - return - } - - if comment.OwnerDid != user.Did { - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) - return - } - - if comment.Deleted != nil { - http.Error(w, "comment already deleted", http.StatusBadRequest) - return - } - - // optimistic deletion - deleted := time.Now() - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) - if err != nil { - log.Println("failed to delete comment") - rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") - return - } - - // delete from pds - if comment.Rkey != "" { - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - log.Println("failed to get authorized client", err) - rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") - return - } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ - Collection: tangled.GraphFollowNSID, - Repo: user.Did, - Rkey: comment.Rkey, - }) - if err != nil { - log.Println(err) - } - } - - // optimistic update for htmx - didHandleMap := map[string]string{ - user.Did: user.Handle, - } - comment.Body = "" - comment.Deleted = &deleted - - // htmx fragment of comment after deletion - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - DidHandleMap: didHandleMap, - Issue: issue, - Comment: comment, - }) - return -} - -func (rp *Repo) RepoIssues(w http.ResponseWriter, r *http.Request) { - params := r.URL.Query() - state := params.Get("state") - isOpen := true - switch state { - case "open": - isOpen = true - case "closed": - isOpen = false - default: - isOpen = true - } - - page, ok := r.Context().Value("page").(pagination.Page) - if !ok { - log.Println("failed to get page") - page = pagination.FirstPage() - } - - user := rp.oauth.GetUser(r) - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) - if err != nil { - log.Println("failed to get issues", err) - rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") - return - } - - identsToResolve := make([]string, len(issues)) - for i, issue := range issues { - identsToResolve[i] = issue.OwnerDid - } - resolvedIds := rp.idResolver.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() - } - } - - rp.pages.RepoIssues(w, pages.RepoIssuesParams{ - LoggedInUser: rp.oauth.GetUser(r), - RepoInfo: f.RepoInfo(user), - Issues: issues, - DidHandleMap: didHandleMap, - FilteringByOpen: isOpen, - Page: page, - }) - return -} - -func (rp *Repo) NewIssue(w http.ResponseWriter, r *http.Request) { - user := rp.oauth.GetUser(r) - - f, err := rp.repoResolver.Resolve(r) - if err != nil { - log.Println("failed to get repo and knot", err) - return - } - - switch r.Method { - case http.MethodGet: - rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(user), - }) - case http.MethodPost: - title := r.FormValue("title") - body := r.FormValue("body") - - if title == "" || body == "" { - rp.pages.Notice(w, "issues", "Title and body are required") - return - } - - tx, err := rp.db.BeginTx(r.Context(), nil) - if err != nil { - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") - return - } - - err = db.NewIssue(tx, &db.Issue{ - RepoAt: f.RepoAt, - Title: title, - Body: body, - OwnerDid: user.Did, - }) - if err != nil { - log.Println("failed to create issue", err) - rp.pages.Notice(w, "issues", "Failed to create issue.") - return - } - - issueId, err := db.GetIssueId(rp.db, f.RepoAt) - if err != nil { - log.Println("failed to get issue id", err) - rp.pages.Notice(w, "issues", "Failed to create issue.") - return - } - - client, err := rp.oauth.AuthorizedClient(r) - if err != nil { - log.Println("failed to get authorized client", err) - rp.pages.Notice(w, "issues", "Failed to create issue.") - return - } - atUri := f.RepoAt.String() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ - Collection: tangled.RepoIssueNSID, - Repo: user.Did, - Rkey: appview.TID(), - Record: &lexutil.LexiconTypeDecoder{ - Val: &tangled.RepoIssue{ - Repo: atUri, - Title: title, - Body: &body, - Owner: user.Did, - IssueId: int64(issueId), - }, - }, - }) - if err != nil { - log.Println("failed to create issue", err) - rp.pages.Notice(w, "issues", "Failed to create issue.") - return - } - - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) - if err != nil { - log.Println("failed to set issue at", err) - rp.pages.Notice(w, "issues", "Failed to create issue.") - return - } - - if !rp.config.Core.Dev { - err = rp.posthog.Enqueue(posthog.Capture{ - DistinctId: user.Did, - Event: "new_issue", - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, - }) - if err != nil { - log.Println("failed to enqueue posthog event:", err) - } - } - - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) - return - } -} - func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) f, err := rp.repoResolver.Resolve(r) diff --git a/appview/repo/router.go b/appview/repo/router.go index c06f3f87..9d4212cf 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -38,26 +38,6 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.Get("/blob/{ref}/*", rp.RepoBlob) r.Get("/raw/{ref}/*", rp.RepoBlobRaw) - r.Route("/issues", func(r chi.Router) { - r.With(middleware.Paginate).Get("/", rp.RepoIssues) - r.Get("/{issue}", rp.RepoSingleIssue) - - r.Group(func(r chi.Router) { - r.Use(middleware.AuthMiddleware(rp.oauth)) - r.Get("/new", rp.NewIssue) - r.Post("/new", rp.NewIssue) - r.Post("/{issue}/comment", rp.NewIssueComment) - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { - r.Get("/", rp.IssueComment) - r.Delete("/", rp.DeleteIssueComment) - r.Get("/edit", rp.EditIssueComment) - r.Post("/edit", rp.EditIssueComment) - }) - r.Post("/{issue}/close", rp.CloseIssue) - r.Post("/{issue}/reopen", rp.ReopenIssue) - }) - }) - r.Route("/fork", func(r chi.Router) { r.Use(middleware.AuthMiddleware(rp.oauth)) r.Get("/", rp.ForkRepo) diff --git a/appview/state/router.go b/appview/state/router.go index beea5be2..5c59be2a 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -6,8 +6,9 @@ import ( "github.com/go-chi/chi/v5" "github.com/gorilla/sessions" + "tangled.sh/tangled.sh/core/appview/issues" "tangled.sh/tangled.sh/core/appview/middleware" - oauth "tangled.sh/tangled.sh/core/appview/oauth/handler" + oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" "tangled.sh/tangled.sh/core/appview/pulls" "tangled.sh/tangled.sh/core/appview/repo" "tangled.sh/tangled.sh/core/appview/settings" @@ -71,7 +72,7 @@ func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { r.Use(mw.GoImport()) r.Mount("/", s.RepoRouter(mw)) - + r.Mount("/issues", s.IssuesRouter(mw)) r.Mount("/pulls", s.PullsRouter(mw)) // These routes get proxied to the knot @@ -155,7 +156,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { func (s *State) OAuthRouter() http.Handler { store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) - oauth := oauth.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) + oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) return oauth.Router() } @@ -170,6 +171,12 @@ func (s *State) SettingsRouter() http.Handler { return settings.Router() } +func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) + return issues.Router(mw) + +} + func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config) return pulls.Router(mw)