From fb6cd3b57303696ebcb7a4b20ddeeeeb4dd809ca Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 11 Mar 2025 19:57:14 +0200 Subject: [PATCH] appview: implement some basic pull request handlers --- appview/db/db.go | 5 +- appview/db/pulls.go | 97 ++++---- appview/pages/pages.go | 27 +++ appview/pages/templates/repo/pulls/new.html | 38 ++++ appview/pages/templates/repo/pulls/pull.html | 133 +++++++++++ appview/state/repo.go | 227 ++++++++++++++++++- appview/state/signer.go | 19 ++ appview/state/state.go | 11 + cmd/gen.go | 4 +- 9 files changed, 505 insertions(+), 56 deletions(-) create mode 100644 appview/pages/templates/repo/pulls/new.html create mode 100644 appview/pages/templates/repo/pulls/pull.html diff --git a/appview/db/db.go b/appview/db/db.go index 00182c46..605798c3 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -109,8 +109,11 @@ func Make(dbPath string) (*DB, error) { repo_at text not null, pull_id integer not null, title text not null, + body text not null, patch text, - patch_at text not null, + pull_at text, + rkey text not null, + target_branch text not null, open integer not null default 1, created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), unique(repo_at, pull_id), diff --git a/appview/db/pulls.go b/appview/db/pulls.go index 92233efa..bf9db112 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -7,30 +7,33 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" ) -type Pulls struct { - ID int `json:"id"` - OwnerDid string `json:"owner_did"` - RepoAt string `json:"repo_at"` - PullId int `json:"pull_id"` - Title string `json:"title"` - Patch string `json:"patch,omitempty"` - PatchAt string `json:"patch_at"` - Open int `json:"open"` - Created time.Time `json:"created"` -} - -type PullComments struct { - ID int `json:"id"` - OwnerDid string `json:"owner_did"` - PullId int `json:"pull_id"` - RepoAt string `json:"repo_at"` - CommentId int `json:"comment_id"` - CommentAt string `json:"comment_at"` - Body string `json:"body"` - Created time.Time `json:"created"` -} - -func NewPull(tx *sql.Tx, pull *Pulls) error { +type Pull struct { + ID int + OwnerDid string + RepoAt syntax.ATURI + PullAt syntax.ATURI + TargetBranch string + Patch string + PullId int + Title string + Body string + Open int + Created time.Time + Rkey string +} + +type PullComment struct { + ID int + OwnerDid string + PullId int + RepoAt string + CommentId int + CommentAt string + Body string + Created time.Time +} + +func NewPull(tx *sql.Tx, pull *Pull) error { defer tx.Rollback() _, err := tx.Exec(` @@ -55,9 +58,9 @@ func NewPull(tx *sql.Tx, pull *Pulls) error { pull.PullId = nextId _, err = tx.Exec(` - insert into pulls (repo_at, owner_did, pull_id, title, patch) - values (?, ?, ?, ?, ?) - `, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.Patch) + insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, patch, rkey) + values (?, ?, ?, ?, ?, ?, ?, ?) + `, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Patch, pull.Rkey) if err != nil { return err } @@ -70,13 +73,13 @@ func NewPull(tx *sql.Tx, pull *Pulls) error { } func SetPullAt(e Execer, repoAt syntax.ATURI, pullId int, pullAt string) error { - _, err := e.Exec(`update pulls set patch_at = ? where repo_at = ? and pull_id = ?`, pullAt, repoAt, pullId) + _, err := e.Exec(`update pulls set pull_at = ? where repo_at = ? and pull_id = ?`, pullAt, repoAt, pullId) return err } func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (string, error) { var pullAt string - err := e.QueryRow(`select patch_at from pulls where repo_at = ? and pull_id = ?`, repoAt, pullId).Scan(&pullAt) + err := e.QueryRow(`select pull_at from pulls where repo_at = ? and pull_id = ?`, repoAt, pullId).Scan(&pullAt) return pullAt, err } @@ -92,19 +95,19 @@ func GetPullOwnerDid(e Execer, repoAt syntax.ATURI, pullId int) (string, error) return ownerDid, err } -func GetPulls(e Execer, repoAt syntax.ATURI) ([]Pulls, error) { - var pulls []Pulls +func GetPulls(e Execer, repoAt syntax.ATURI) ([]Pull, error) { + var pulls []Pull - rows, err := e.Query(`select owner_did, pull_id, created, title, patch, open from pulls where repo_at = ? order by created desc`, repoAt) + rows, err := e.Query(`select owner_did, pull_id, created, title, open, target_branch, pull_at, body, patch, rkey from pulls where repo_at = ? order by created desc`, repoAt) if err != nil { return nil, err } defer rows.Close() for rows.Next() { - var pull Pulls + var pull Pull var createdAt string - err := rows.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.Patch, &pull.Open) + err := rows.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.Open, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey) if err != nil { return nil, err } @@ -125,13 +128,13 @@ func GetPulls(e Execer, repoAt syntax.ATURI) ([]Pulls, error) { return pulls, nil } -func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pulls, error) { - query := `select owner_did, created, title, patch, open from pulls where repo_at = ? and pull_id = ?` +func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { + query := `select owner_did, created, title, open, target_branch, pull_at, body, patch, rkey from pulls where repo_at = ? and pull_id = ?` row := e.QueryRow(query, repoAt, pullId) - var pull Pulls + var pull Pull var createdAt string - err := row.Scan(&pull.OwnerDid, &createdAt, &pull.Title, &pull.Patch, &pull.Open) + err := row.Scan(&pull.OwnerDid, &createdAt, &pull.Title, &pull.Open, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey) if err != nil { return nil, err } @@ -145,13 +148,13 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pulls, error) { return &pull, nil } -func GetPullWithComments(e Execer, repoAt syntax.ATURI, pullId int) (*Pulls, []PullComments, error) { - query := `select owner_did, pull_id, created, title, patch, open from pulls where repo_at = ? and pull_id = ?` +func GetPullWithComments(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, []PullComment, error) { + query := `select owner_did, pull_id, created, title, open, target_branch, pull_at, body, patch, rkey from pulls where repo_at = ? and pull_id = ?` row := e.QueryRow(query, repoAt, pullId) - var pull Pulls + var pull Pull var createdAt string - err := row.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.Patch, &pull.Open) + err := row.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.Open, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey) if err != nil { return nil, nil, err } @@ -170,7 +173,7 @@ func GetPullWithComments(e Execer, repoAt syntax.ATURI, pullId int) (*Pulls, []P return &pull, comments, nil } -func NewPullComment(e Execer, comment *PullComments) error { +func NewPullComment(e Execer, comment *PullComment) error { query := `insert into pull_comments (owner_did, repo_at, comment_at, pull_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` _, err := e.Exec( query, @@ -184,12 +187,12 @@ func NewPullComment(e Execer, comment *PullComments) error { return err } -func GetPullComments(e Execer, repoAt syntax.ATURI, pullId int) ([]PullComments, error) { - var comments []PullComments +func GetPullComments(e Execer, repoAt syntax.ATURI, pullId int) ([]PullComment, error) { + var comments []PullComment rows, err := e.Query(`select owner_did, pull_id, comment_id, comment_at, body, created from pull_comments where repo_at = ? and pull_id = ? order by created asc`, repoAt, pullId) if err == sql.ErrNoRows { - return []PullComments{}, nil + return []PullComment{}, nil } if err != nil { return nil, err @@ -197,7 +200,7 @@ func GetPullComments(e Execer, repoAt syntax.ATURI, pullId int) ([]PullComments, defer rows.Close() for rows.Next() { - var comment PullComments + var comment PullComment var createdAt string err := rows.Scan(&comment.OwnerDid, &comment.PullId, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt) if err != nil { diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 010c4aa0..d0eb8d81 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -506,10 +506,23 @@ func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { return p.executeRepo("repo/issues/new", w, params) } +type RepoNewPullParams struct { + LoggedInUser *auth.User + RepoInfo RepoInfo + Active string +} + +func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { + params.Active = "pulls" + return p.executeRepo("repo/pulls/new", w, params) +} + type RepoPullsParams struct { LoggedInUser *auth.User RepoInfo RepoInfo + Pulls []db.Pull Active string + DidHandleMap map[string]string } func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { @@ -517,6 +530,20 @@ func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { return p.executeRepo("repo/pulls/pulls", w, params) } +type RepoSinglePullParams struct { + LoggedInUser *auth.User + RepoInfo RepoInfo + DidHandleMap map[string]string + Pull db.Pull + Comments []db.PullComment + Active string +} + +func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { + params.Active = "pulls" + return p.executeRepo("repo/pulls/pull", w, params) +} + func (p *Pages) Static() http.Handler { sub, err := fs.Sub(files, "static") if err != nil { diff --git a/appview/pages/templates/repo/pulls/new.html b/appview/pages/templates/repo/pulls/new.html new file mode 100644 index 00000000..f7884cc1 --- /dev/null +++ b/appview/pages/templates/repo/pulls/new.html @@ -0,0 +1,38 @@ +{{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }} + +{{ define "repoContent" }} +
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+{{ end }} diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html new file mode 100644 index 00000000..bd30054d --- /dev/null +++ b/appview/pages/templates/repo/pulls/pull.html @@ -0,0 +1,133 @@ +{{ define "title" }} + {{ .Pull.Title }} · + {{ .RepoInfo.FullName }} +{{ end }} + +{{ define "repoContent" }} +

+ {{ .Pull.Title }} + #{{ .Pull.PullId }} +

+ + {{ $bgColor := "bg-gray-800" }} + {{ $icon := "ban" }} + {{ if eq .State "open" }} + {{ $bgColor = "bg-green-600" }} + {{ $icon = "circle-dot" }} + {{ end }} + + +
+
+
+ + {{ .State }} +
+ + opened by + {{ $owner := didOrHandle .Pull.OwnerDid .PullOwnerHandle }} + {{ $owner }} + + + +
+ + {{ if .Pull.Body }} +
+ {{ .Pull.Body | markdown }} +
+ {{ end }} +
+{{ end }} + +{{ define "repoAfter" }} +
+ {{ range $index, $comment := .Comments }} +
+ {{ if eq $index 0 }} +
+ {{ else }} +
+ {{ end }} +
+ {{ $owner := index $.DidHandleMap .OwnerDid }} + + {{ $owner }} + + + + {{ .Created | timeFmt }} + +
+
+ {{ .Body | markdown }} +
+
+ {{ end }} +
+ + {{ if .LoggedInUser }} +
+ + +
+
+ {{ end }} + + {{ if eq .LoggedInUser.Did .Pull.OwnerDid }} + {{ $action := "close" }} + {{ $icon := "circle-x" }} + {{ $hoverColor := "red" }} + {{ if eq .State "closed" }} + {{ $action = "reopen" }} + {{ $icon = "circle-dot" }} + {{ $hoverColor = "green" }} + {{ end }} +
+ +
+
+ {{ end }} +{{ end }} diff --git a/appview/state/repo.go b/appview/state/repo.go index 0553c8df..57c29e92 100644 --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -234,6 +234,200 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { } } +// MergeCheck gets called async, every time the patch diff is updated in a pull. +func (s *State) MergeCheck(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") + return + } + + patch := r.FormValue("patch") + targetBranch := r.FormValue("targetBranch") + + if patch == "" || targetBranch == "" { + s.pages.Notice(w, "pull", "Patch and target branch are required.") + return + } + + secret, err := db.GetRegistrationKey(s.db, f.Knot) + if err != nil { + log.Printf("no key found for domain %s: %s\n", f.Knot, err) + s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") + return + } + + ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) + if err != nil { + log.Printf("failed to create signed client for %s", f.Knot) + s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") + return + } + + resp, err := ksClient.MergeCheck([]byte(patch), user.Did, f.RepoName, targetBranch) + if err != nil { + log.Println("failed to check mergeability", err) + s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.") + return + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Println("failed to read knotserver response body") + s.pages.Notice(w, "pull", "Unable to check for mergeability. Try again later.") + return + } + + var mergeCheckResponse types.MergeCheckResponse + err = json.Unmarshal(respBody, &mergeCheckResponse) + if err != nil { + log.Println("failed to unmarshal merge check response", err) + s.pages.Notice(w, "pull", "Failed to check mergeability. Try again later.") + return + } + + // TODO: this has to return a html fragment + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mergeCheckResponse) +} + +func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + switch r.Method { + case http.MethodGet: + s.pages.RepoNewPull(w, pages.RepoNewPullParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + }) + case http.MethodPost: + title := r.FormValue("title") + body := r.FormValue("body") + targetBranch := r.FormValue("targetBranch") + patch := r.FormValue("patch") + + if title == "" || body == "" || patch == "" { + s.pages.Notice(w, "pull", "Title, body and patch diff are required.") + 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 func() { + tx.Rollback() + err = s.enforcer.E.LoadPolicy() + if err != nil { + log.Println("failed to rollback policies") + } + }() + + err = db.NewPull(tx, &db.Pull{ + Title: title, + Body: body, + TargetBranch: targetBranch, + Patch: patch, + OwnerDid: user.Did, + RepoAt: f.RepoAt, + }) + 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 + } + client, _ := s.auth.AuthorizedClient(r) + pullId, err := db.GetPullId(s.db, 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 + } + + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoPullNSID, + Repo: user.Did, + Rkey: s.TID(), + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.RepoPull{ + Title: title, + PullId: int64(pullId), + TargetRepo: string(f.RepoAt), + TargetBranch: targetBranch, + Patch: patch, + }, + }, + }) + + err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) + 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 + } + + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) + return + } +} + +func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(r) + if err != nil { + log.Println("failed to get repo and knot", err) + return + } + + prId := chi.URLParam(r, "pull") + prIdInt, err := strconv.Atoi(prId) + if err != nil { + http.Error(w, "bad pr id", http.StatusBadRequest) + log.Println("failed to parse pr id", err) + return + } + + pr, comments, err := db.GetPullWithComments(s.db, f.RepoAt, prIdInt) + if err != nil { + log.Println("failed to get pr and comments", err) + s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.") + return + } + + identsToResolve := make([]string, len(comments)) + for i, comment := range comments { + identsToResolve[i] = comment.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.RepoSinglePull(w, pages.RepoSinglePullParams{ + LoggedInUser: user, + RepoInfo: f.RepoInfo(s, user), + Pull: *pr, + Comments: comments, + + DidHandleMap: didHandleMap, + }) +} + func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { f, err := fullyResolvedRepo(r) if err != nil { @@ -1071,13 +1265,34 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { return } - switch r.Method { - case http.MethodGet: - s.pages.RepoPulls(w, pages.RepoPullsParams{ - LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), - }) + pulls, err := db.GetPulls(s.db, f.RepoAt) + if err != nil { + log.Println("failed to get pulls", err) + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") + return } + + 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.auth.GetUser(r), + RepoInfo: f.RepoInfo(s, user), + Pulls: pulls, + DidHandleMap: didHandleMap, + }) + return } func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { diff --git a/appview/state/signer.go b/appview/state/signer.go index 5b9e2c4b..35702ddd 100644 --- a/appview/state/signer.go +++ b/appview/state/signer.go @@ -174,3 +174,22 @@ func (s *SignedClient) Merge(patch []byte, ownerDid, targetRepo, branch string) return s.client.Do(req) } + +func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { + const ( + Method = "POST" + ) + endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) + + body, _ := json.Marshal(map[string]interface{}{ + "patch": string(patch), + "branch": branch, + }) + + req, err := s.newRequest(Method, endpoint, body) + if err != nil { + return nil, err + } + + return s.client.Do(req) +} diff --git a/appview/state/state.go b/appview/state/state.go index 7f142c26..7c0b4fb5 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -881,6 +881,17 @@ func (s *State) UserRouter() http.Handler { r.Route("/pulls", func(r chi.Router) { r.Get("/", s.RepoPulls) + r.Get("/{pull}", s.RepoSinglePull) + + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware(s)) + r.Get("/new", s.NewPull) + r.Post("/new", s.NewPull) + // r.Post("/{pull}/comment", s.PullComment) + // r.Post("/{pull}/close", s.ClosePull) + // r.Post("/{pull}/reopen", s.ReopenPull) + // r.Post("/{pull}/merge", s.MergePull) + }) }) // These routes get proxied to the knot diff --git a/cmd/gen.go b/cmd/gen.go index 9e730119..2824054b 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -22,8 +22,8 @@ func main() { shtangled.RepoIssueState{}, shtangled.RepoIssue{}, shtangled.Repo{}, - shtangled.RepoPullPatch{}, - shtangled.RepoPullState{}, + shtangled.RepoPull{}, + shtangled.RepoPullStatus{}, shtangled.RepoPullComment{}, ); err != nil { panic(err) -- 2.51.2