forked from tangled.org/core
this repo has no description

add comment and resubmit actions in fragments

improve merge check a bit too

+5 -1
appview/db/pulls.go
···
}
func (p *Pull) LatestPatch() string {
-
latestSubmission := p.Submissions[len(p.Submissions)-1]
+
latestSubmission := p.Submissions[p.LastRoundNumber()]
return latestSubmission.Patch
+
}
+
+
func (p *Pull) LastRoundNumber() int {
+
return len(p.Submissions) - 1
}
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
+15
appview/pages/funcmap.go
···
package pages
import (
+
"errors"
"fmt"
"html"
"html/template"
···
},
"list": func(args ...any) []any {
return args
+
},
+
"dict": func(values ...any) (map[string]any, error) {
+
if len(values)%2 != 0 {
+
return nil, errors.New("invalid dict call")
+
}
+
dict := make(map[string]any, len(values)/2)
+
for i := 0; i < len(values); i += 2 {
+
key, ok := values[i].(string)
+
if !ok {
+
return nil, errors.New("dict keys must be strings")
+
}
+
dict[key] = values[i+1]
+
}
+
return dict, nil
},
"i": func(name string, classes ...string) template.HTML {
data, err := icon(name, classes)
+34
appview/pages/pages.go
···
return p.execute("repo/pulls/patch", w, params)
}
+
type PullResubmitParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
SubmissionId int
+
}
+
+
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
+
return p.executePlain("fragments/pullResubmit", w, params)
+
}
+
+
type PullActionsParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
RoundNumber int
+
MergeCheck types.MergeCheckResponse
+
}
+
+
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
+
return p.executePlain("fragments/pullActions", w, params)
+
}
+
+
type PullNewCommentParams struct {
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
RoundNumber int
+
}
+
+
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
+
return p.executePlain("fragments/pullNewComment", w, params)
+
}
+
func (p *Pages) Static() http.Handler {
sub, err := fs.Sub(Files, "static")
if err != nil {
+2 -2
appview/pages/templates/fragments/editRepoDescription.html
···
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
<button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm">
-
save {{ i "check" "w-3 h-3" }}
+
{{ i "check" "w-3 h-3" }} save
</button>
<button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
-
cancel {{ i "x" "w-3 h-3" }}
+
{{ i "x" "w-3 h-3" }} cancel
</button>
</form>
{{ end }}
+72
appview/pages/templates/fragments/pullActions.html
···
+
{{ define "fragments/pullActions" }}
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
+
{{ $roundNumber := .RoundNumber }}
+
+
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
+
{{ $isMerged := .Pull.State.IsMerged }}
+
{{ $isClosed := .Pull.State.IsClosed }}
+
{{ $isOpen := .Pull.State.IsOpen }}
+
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
+
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
+
{{ $isLastRound := eq $roundNumber $lastIdx }}
+
<div class="relative w-fit">
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
+
{{ i "message-square-plus" "w-4 h-4" }}
+
<span>comment</span>
+
</button>
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
+
{{ $disabled := "" }}
+
{{ if $isConflicted }}
+
{{ $disabled = "disabled" }}
+
{{ end }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
hx-confirm="Are you sure you want to merge this pull request?"
+
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
+
{{ i "git-merge" "w-4 h-4" }}
+
<span>merge</span>
+
</button>
+
{{ end }}
+
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
+
<button
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-target="#actions-{{$roundNumber}}"
+
hx-swap="outerHtml"
+
class="btn p-2 flex items-center gap-2">
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
</button>
+
{{ end }}
+
+
{{ if and $isPullAuthor $isPushAllowed $isOpen $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2">
+
{{ i "ban" "w-4 h-4" }}
+
<span>close</span>
+
</button>
+
{{ end }}
+
+
{{ if and $isPullAuthor $isPushAllowed $isClosed $isLastRound }}
+
<button
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
+
hx-swap="none"
+
class="btn p-2 flex items-center gap-2">
+
{{ i "circle-dot" "w-4 h-4" }}
+
<span>reopen</span>
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ end }}
+
+
+32
appview/pages/templates/fragments/pullNewComment.html
···
+
{{ define "fragments/pullNewComment" }}
+
<div
+
id="pull-comment-card-{{ .RoundNumber }}"
+
class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2">
+
<div class="text-sm text-gray-500">
+
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
+
</div>
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+
hx-swap="none"
+
class="w-full flex flex-wrap gap-2">
+
<textarea
+
name="body"
+
class="w-full p-2 rounded border border-gray-200"
+
placeholder="Add to the discussion..."></textarea>
+
<button type="submit" class="btn flex items-center gap-2">
+
{{ i "message-square" "w-4 h-4" }} comment
+
</button>
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
+
hx-swap="outerHTML"
+
hx-target="#pull-comment-card-{{ .RoundNumber }}">
+
{{ i "x" "w-4 h-4" }}
+
<span>cancel</span>
+
</button>
+
<div id="pull-comment"></div>
+
</form>
+
</div>
+
{{ end }}
+
+52
appview/pages/templates/fragments/pullResubmit.html
···
+
{{ define "fragments/pullResubmit" }}
+
<div
+
id="resubmit-pull-card"
+
class="rounded relative border bg-amber-50 border-amber-200 px-6 py-2">
+
+
<div class="flex items-center gap-2 text-amber-500">
+
{{ i "pencil" "w-4 h-4" }}
+
<span class="font-medium">resubmit your patch</span>
+
</div>
+
+
<div class="mt-2 text-sm text-gray-700">
+
You can update this patch to address any reviews.
+
This will begin a new round of reviews,
+
but you'll still be able to view your previous submissions and feedback.
+
</div>
+
+
<div class="mt-4 flex flex-col">
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
+
hx-swap="none"
+
class="w-full flex flex-wrap gap-2">
+
<textarea
+
name="patch"
+
class="w-full p-2 mb-2 rounded border border-gray-200"
+
placeholder="Paste your updated patch here."
+
rows="15"
+
></textarea>
+
<button
+
type="submit"
+
class="btn flex items-center gap-2"
+
{{ if or .Pull.State.IsClosed }}
+
disabled
+
{{ end }}>
+
{{ i "rotate-ccw" "w-4 h-4" }}
+
<span>resubmit</span>
+
</button>
+
<button
+
type="button"
+
class="btn flex items-center gap-2"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
+
hx-swap="outerHTML"
+
hx-target="#resubmit-pull-card">
+
{{ i "x" "w-4 h-4" }}
+
<span>cancel</span>
+
</button>
+
</form>
+
+
<div id="resubmit-error" class="error"></div>
+
<div id="resubmit-success" class="success"></div>
+
</div>
+
</div>
+
{{ end }}
+1 -2
appview/pages/templates/fragments/repoDescription.html
···
{{ if .RepoInfo.Roles.IsOwner }}
<button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
-
edit
-
{{ i "pencil" "w-3 h-3" }}
+
{{ i "pencil" "w-3 h-3" }} edit
</button>
{{ end }}
</span>
+22 -67
appview/pages/templates/repo/pulls/pull.html
···
</div>
{{ end }}
-
{{ block "mergeStatus" $ }} {{ end }}
+
{{ if eq $lastIdx .RoundNumber }}
+
{{ block "mergeStatus" $ }} {{ end }}
+
{{ end }}
{{ if $.LoggedInUser }}
-
{{ block "actions" (list $ .ID) }} {{ end }}
+
{{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }}
{{ else }}
<div class="bg-white rounded drop-shadow-sm px-6 py-4 w-fit">
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
···
{{ end }}
{{ define "mergeStatus" }}
-
{{ if .Pull.State.IsMerged }}
+
{{ if .Pull.State.IsClosed }}
+
<div class="bg-gray-50 border border-black rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
+
<div class="flex items-center gap-2 text-black">
+
{{ i "ban" "w-4 h-4" }}
+
<span class="font-medium">closed without merging</span
+
>
+
</div>
+
</div>
+
{{ else if .Pull.State.IsMerged }}
<div class="bg-purple-50 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
<div class="flex items-center gap-2 text-purple-500">
···
>
</div>
</div>
+
{{ else if and .MergeCheck .MergeCheck.Error }}
+
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
+
<div class="flex items-center gap-2 text-red-500">
+
{{ i "triangle-alert" "w-4 h-4" }}
+
<span class="font-medium">{{ .MergeCheck.Error }}</span>
+
</div>
+
</div>
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
···
<span class="font-medium">no conflicts, ready to merge</span>
</div>
</div>
-
{{ end }}
-
{{ end }}
-
-
{{ define "actions" }}
-
{{ $rootObj := index . 0 }}
-
{{ $submissionId := index . 1 }}
-
-
{{ with $rootObj }}
-
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
-
{{ $isMerged := .Pull.State.IsMerged }}
-
{{ $isClosed := .Pull.State.IsClosed }}
-
{{ $isOpen := .Pull.State.IsOpen }}
-
{{ $isConflicted := and .MergeCheck .MergeCheck.IsConflicted }}
-
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
-
<div class="relative w-fit">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
-
<div class="flex flex-wrap gap-2">
-
<button href="#" class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
-
{{ i "message-square-plus" "w-4 h-4" }}
-
<span>comment</span>
-
</button>
-
{{ if and $isPushAllowed $isOpen }}
-
{{ $disabled := "" }}
-
{{ if $isConflicted }}
-
{{ $disabled = "disabled" }}
-
{{ end }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
-
hx-swap="none"
-
hx-confirm="Are you sure you want to merge this pull request?"
-
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
-
{{ i "git-merge" "w-4 h-4" }}
-
<span>merge</span>
-
</button>
-
{{ end }}
-
-
{{ if and $isPullAuthor $isOpen }}
-
<button href="#" class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
-
{{ i "rotate-ccw" "w-4 h-4" }}
-
<span>resubmit</span>
-
</button>
-
{{ end }}
-
-
{{ if and $isPullAuthor $isPushAllowed $isOpen }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2">
-
{{ i "ban" "w-4 h-4" }}
-
<span>close</span>
-
</button>
-
{{ end }}
-
-
{{ if and $isPullAuthor $isPushAllowed $isClosed }}
-
<button
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
-
hx-swap="none"
-
class="btn p-2 flex items-center gap-2">
-
{{ i "circle-dot" "w-4 h-4" }}
-
<span>reopen</span>
-
</button>
-
{{ end }}
-
</div>
-
</div>
{{ end }}
{{ end }}
+124 -39
appview/state/pull.go
···
lexutil "github.com/bluesky-social/indigo/lex/util"
)
+
// htmx fragment
+
func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(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 = 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)
+
+
s.pages.PullActionsFragment(w, pages.PullActionsParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
Pull: pull,
+
RoundNumber: roundNumber,
+
MergeCheck: mergeCheckResponse,
+
})
+
return
+
}
+
}
+
func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
···
}
}
-
var mergeCheckResponse types.MergeCheckResponse
-
-
// Only perform merge check if the pull request is not already merged
-
if pull.State != db.PullMerged {
-
secret, err := db.GetRegistrationKey(s.db, f.Knot)
-
if err != nil {
-
log.Printf("failed to get registration key for %s", f.Knot)
-
s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.")
-
return
-
}
-
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
-
if err == nil {
-
resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), pull.OwnerDid, f.RepoName, pull.TargetBranch)
-
if err != nil {
-
log.Println("failed to check for mergeability:", err)
-
} else {
-
respBody, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Println("failed to read merge check response body")
-
} else {
-
err = json.Unmarshal(respBody, &mergeCheckResponse)
-
if err != nil {
-
log.Println("failed to unmarshal merge check response", err)
-
}
-
}
-
}
-
} else {
-
log.Printf("failed to setup signed client for %s; ignoring...", f.Knot)
-
}
-
}
+
mergeCheckResponse := s.mergeCheck(f, pull)
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
LoggedInUser: user,
···
})
}
+
func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) 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: %w", err)
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: this knot is unregistered",
+
}
+
}
+
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.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",
+
}
+
}
+
+
resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), pull.OwnerDid, f.RepoName, pull.TargetBranch)
+
if err != nil {
+
log.Println("failed to check for mergeability:", err)
+
switch resp.StatusCode {
+
case 400:
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: does this knot support PRs?",
+
}
+
default:
+
return types.MergeCheckResponse{
+
Error: "failed to check merge status: this knot is unreachable",
+
}
+
}
+
}
+
+
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 *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
···
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(s, 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
-
}
-
-
submissionIdstr := r.FormValue("submissionId")
-
submissionId, err := strconv.Atoi(submissionIdstr)
-
if err != nil {
-
s.pages.Notice(w, "pull", "Invalid comment submission.")
return
}
···
},
},
})
+
log.Println(atResp.Uri)
if err != nil {
log.Println("failed to create pull comment", err)
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
PullId: pull.PullId,
Body: body,
CommentAt: atResp.Uri,
-
SubmissionId: submissionId,
+
SubmissionId: pull.Submissions[roundNumber].ID,
})
if err != nil {
log.Println("failed to create pull comment", err)
···
}
switch r.Method {
+
case http.MethodGet:
+
s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
+
RepoInfo: f.RepoInfo(s, user),
+
Pull: pull,
+
})
+
return
case http.MethodPost:
patch := r.FormValue("patch")
+17 -3
appview/state/router.go
···
r.Route("/{pull}", func(r chi.Router) {
r.Use(ResolvePull(s))
r.Get("/", s.RepoSinglePull)
-
r.Get("/round/{round}", s.RepoPullPatch)
+
+
r.Route("/round/{round}", func(r chi.Router) {
+
r.Get("/", s.RepoPullPatch)
+
r.Get("/actions", s.PullActions)
+
r.Route("/comment", func(r chi.Router) {
+
r.Get("/", s.PullComment)
+
r.Post("/", s.PullComment)
+
})
+
})
// authorized requests below this point
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(s))
-
r.Post("/resubmit", s.ResubmitPull)
-
r.Post("/comment", s.PullComment)
+
r.Route("/resubmit", func(r chi.Router) {
+
r.Get("/", s.ResubmitPull)
+
r.Post("/", s.ResubmitPull)
+
})
+
r.Route("/comment", func(r chi.Router) {
+
r.Get("/", s.PullComment)
+
r.Post("/", s.PullComment)
+
})
r.Post("/close", s.ClosePull)
r.Post("/reopen", s.ReopenPull)
// collaborators only