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

appview: merge pulls and associated ui changes

anirudh.fi cd8dfb18 c2f38ac4

verified
Changed files
+461 -89
appview
db
pages
templates
layouts
repo
state
+7 -6
appview/db/pulls.go
···
type PullState int
const (
-
PullOpen PullState = iota
+
PullClosed PullState = iota
+
PullOpen
PullMerged
-
PullClosed
)
func (p PullState) String() string {
···
}
pull.PullId = nextId
+
pull.State = PullOpen
_, err = tx.Exec(`
-
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)
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, patch, rkey, state)
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
+
`, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Patch, pull.Rkey, pull.State)
if err != nil {
return err
}
···
pull_at,
body,
patch,
-
rkey
+
rkey
from
pulls
where
+7 -2
appview/pages/pages.go
···
func (r RepoInfo) TabMetadata() map[string]any {
meta := make(map[string]any)
-
meta["issues"] = r.Stats.IssueCount.Open
-
meta["pulls"] = r.Stats.PullCount.Open
+
if r.Stats.PullCount.Open > 0 {
+
meta["pulls"] = r.Stats.PullCount.Open
+
}
+
+
if r.Stats.IssueCount.Open > 0 {
+
meta["issues"] = r.Stats.IssueCount.Open
+
}
// more stuff?
+1 -1
appview/pages/templates/layouts/repobase.html
···
>
{{ $key }}
{{ if not (isNil $meta) }}
-
<span class="bg-gray-200 rounded py-1/2 px-1 text-sm font-mono">{{ $meta }}</span>
+
<span class="bg-gray-200 rounded py-1/2 px-1 text-sm">{{ $meta }}</span>
{{ end }}
</div>
</a>
+4 -4
appview/pages/templates/repo/pulls/new.html
···
>
<div class="flex flex-col gap-4">
<div>
-
<label for="title">title</label>
+
<label for="title">write a title</label>
<input type="text" name="title" id="title" class="w-full" />
-
<label for="targetBranch">target branch</label>
+
<label for="targetBranch">select a target branch</label>
<p class="text-gray-500">
The branch you want to make your change against.
</p>
<select name="targetBranch" class="p-1 border border-gray-200 bg-white">
-
<option disabled selected>Select a branch</option>
+
<option disabled selected>select a branch</option>
{{ range .Branches }}
<option
value="{{ .Reference.Name }}"
···
</select>
</div>
<div>
-
<label for="body">body</label>
+
<label for="body">add a description</label>
<textarea
name="body"
id="body"
+176 -53
appview/pages/templates/repo/pulls/pull.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-4">
<h1 class="text-2xl">
{{ .Pull.Title }}
···
{{ if .Pull.State.IsOpen }}
{{ $bgColor = "bg-green-600" }}
-
{{ $icon = "circle-dot" }}
+
{{ $icon = "git-pull-request" }}
{{ else if .Pull.State.IsMerged }}
{{ $bgColor = "bg-purple-600" }}
{{ $icon = "git-merge" }}
···
type="button"
class="btn btn-sm"
onclick="togglePatchEdit(true)"
+
{{ if or .Pull.State.IsMerged .Pull.State.IsClosed }}
+
disabled title="Cannot edit closed or merged
+
pull requests"
+
{{ end }}
>
<i data-lucide="edit" class="w-4 h-4 mr-1"></i>Edit
</button>
···
</script>
</details>
</div>
-
-
{{ if .MergeCheck }}
-
<div class="mt-4" id="merge-check">
-
<div
-
class="rounded-sm border p-4 {{ if .MergeCheck.IsConflicted }}
-
bg-red-50 border-red-200
-
{{ else }}
-
bg-green-50 border-green-200
-
{{ end }}"
-
>
-
<div
-
class="flex items-center gap-2 rounded-sm {{ if .MergeCheck.IsConflicted }}
-
text-red-500
-
{{ else }}
-
text-green-500
-
{{ end }}"
-
>
-
{{ if .MergeCheck.IsConflicted }}
-
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
-
<span class="font-medium"
-
>merge conflicts detected</span
-
>
-
{{ else }}
-
<i data-lucide="check-circle" class="w-4 h-4"></i>
-
<span class="font-medium">ready to merge</span>
-
{{ end }}
-
</div>
-
-
{{ if .MergeCheck.IsConflicted }}
-
<div class="mt-2">
-
<ul class="text-sm space-y-1">
-
{{ range .MergeCheck.Conflicts }}
-
<li class="flex items-center">
-
<i
-
data-lucide="file-warning"
-
class="w-3 h-3 mr-1.5 text-red-500"
-
></i>
-
<span class="font-mono"
-
>{{ slice .Filename 0 (sub (len .Filename) 2) }}</span
-
>
-
</li>
-
{{ end }}
-
</ul>
-
</div>
-
{{ end }}
-
</div>
-
</div>
-
{{ end }}
{{ end }}
{{ define "repoAfter" }}
···
</div>
{{ end }}
+
{{ if .Pull.State.IsMerged }}
+
<div
+
id="merge-status-card"
+
class="rounded relative bg-purple-50 border border-purple-200 p-4"
+
>
+
{{ if gt (len .Comments) 0 }}
+
<div
+
class="absolute left-8 -top-4 w-px h-4 bg-gray-300"
+
></div>
+
{{ else }}
+
<div
+
class="absolute left-8 -top-8 w-px h-8 bg-gray-300"
+
></div>
+
{{ end }}
+
+
+
<div class="flex items-center gap-2 text-purple-500">
+
<i data-lucide="git-merge" class="w-4 h-4"></i>
+
<span class="font-medium"
+
>Pull request successfully merged</span
+
>
+
</div>
+
+
<div class="mt-2 text-sm text-gray-700">
+
<p>
+
This pull request has been merged into the base branch.
+
</p>
+
</div>
+
</div>
+
{{ else if .MergeCheck }}
+
<div
+
id="merge-status-card"
+
class="rounded relative {{ if .MergeCheck.IsConflicted }}
+
bg-red-50 border border-red-200
+
{{ else }}
+
bg-green-50 border border-green-200
+
{{ end }} p-4"
+
>
+
{{ if gt (len .Comments) 0 }}
+
<div
+
class="absolute left-8 -top-4 w-px h-4 bg-gray-300"
+
></div>
+
{{ else }}
+
<div
+
class="absolute left-8 -top-8 w-px h-8 bg-gray-300"
+
></div>
+
{{ end }}
+
+
+
<div
+
class="flex items-center gap-2 {{ if .MergeCheck.IsConflicted }}
+
text-red-500
+
{{ else }}
+
text-green-500
+
{{ end }}"
+
>
+
{{ if .MergeCheck.IsConflicted }}
+
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
+
<span class="font-medium"
+
>merge conflicts detected</span
+
>
+
{{ else }}
+
<i data-lucide="check-circle" class="w-4 h-4"></i>
+
<span class="font-medium">ready to merge</span>
+
{{ end }}
+
</div>
+
+
{{ if .MergeCheck.IsConflicted }}
+
<div class="mt-2">
+
<ul class="text-sm space-y-1">
+
{{ range .MergeCheck.Conflicts }}
+
<li class="flex items-center">
+
<i
+
data-lucide="file-warning"
+
class="w-3 h-3 mr-1.5 text-red-500"
+
></i>
+
<span class="font-mono"
+
>{{ slice .Filename 0 (sub (len .Filename) 2) }}</span
+
>
+
</li>
+
{{ end }}
+
</ul>
+
</div>
+
<div class="mt-3 text-sm text-gray-700">
+
<p>
+
Please resolve these conflicts locally and update
+
the patch to continue with the merge.
+
</p>
+
</div>
+
{{ else }}
+
<div class="mt-2 text-sm text-gray-700">
+
<p>
+
No conflicts detected with the base branch. This
+
pull request can be merged safely.
+
</p>
+
</div>
+
{{ if and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid) }}
+
<div class="mt-4 flex items-center gap-2">
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
+
hx-swap="none"
+
>
+
<input
+
type="hidden"
+
name="targetBranch"
+
value="{{ .Pull.TargetBranch }}"
+
/>
+
<input
+
type="hidden"
+
name="patch"
+
value="{{ .Pull.Patch }}"
+
/>
+
<button
+
type="submit"
+
class="btn flex items-center gap-2"
+
{{ if or .Pull.State.IsClosed .MergeCheck.IsConflicted }}
+
disabled
+
{{ end }}
+
>
+
<i
+
data-lucide="git-merge"
+
class="w-4 h-4 text-purple-500"
+
></i>
+
<span>merge</span>
+
</button>
+
</form>
+
+
{{ if or (eq .LoggedInUser.Did .Pull.OwnerDid) (eq .LoggedInUser.Did .RepoInfo.OwnerDid) }}
+
{{ $action := "close" }}
+
{{ $icon := "circle-x" }}
+
{{ $hoverColor := "red" }}
+
{{ if .Pull.State.IsClosed }}
+
{{ $action = "reopen" }}
+
{{ $icon = "circle-dot" }}
+
{{ $hoverColor = "green" }}
+
{{ end }}
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ $action }}"
+
hx-swap="none"
+
>
+
<button
+
type="submit"
+
class="btn flex items-center gap-2"
+
>
+
<i
+
data-lucide="{{ $icon }}"
+
class="w-4 h-4 text-{{ $hoverColor }}-400"
+
></i>
+
<span>{{ $action }}</span>
+
</button>
+
</form>
+
<div id="pull-merge-error" class="error"></div>
+
<div
+
id="pull-merge-success"
+
class="success"
+
></div>
+
{{ end }}
+
</div>
+
{{ end }}
+
{{ end }}
+
</div>
+
{{ end }}
</section>
{{ if .LoggedInUser }}
<form
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/comment"
class="mt-8"
+
hx-swap="none"
>
<textarea
name="body"
···
</form>
{{ end }}
-
{{ if eq .LoggedInUser.Did .Pull.OwnerDid }}
+
{{ if and (or (eq .LoggedInUser.Did .Pull.OwnerDid) (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) (not .MergeCheck) (not .Pull.State.IsMerged) }}
{{ $action := "close" }}
{{ $icon := "circle-x" }}
{{ $hoverColor := "red" }}
···
<form
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/{{ $action }}"
hx-swap="none"
-
class="mt-8">
-
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
+
class="mt-8"
+
>
+
<button type="submit" class="btn text-sm flex items-center gap-2">
<i
data-lucide="{{ $icon }}"
class="w-4 h-4 mr-2 text-{{ $hoverColor }}-400"
···
</button>
</form>
{{ end }}
+
+
+
<div id="pull-close"></div>
+
<div id="pull-reopen"></div>
{{ end }}
+259 -18
appview/state/repo.go
···
}
}
-
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
-
}
-
var mergeCheckResponse types.MergeCheckResponse
-
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
-
if err == nil {
-
resp, err := ksClient.MergeCheck([]byte(pr.Patch), pr.OwnerDid, f.RepoName, pr.TargetBranch)
+
+
// Only perform merge check if the pull request is not already merged
+
if pr.State != db.PullMerged {
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
if err != nil {
-
log.Println("failed to check for mergeability:", err)
-
} else {
-
respBody, err := io.ReadAll(resp.Body)
+
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(pr.Patch), pr.OwnerDid, f.RepoName, pr.TargetBranch)
if err != nil {
-
log.Println("failed to read merge check response body")
+
log.Println("failed to check for mergeability:", err)
} else {
-
err = json.Unmarshal(respBody, &mergeCheckResponse)
+
respBody, err := io.ReadAll(resp.Body)
if err != nil {
-
log.Println("failed to unmarshal merge check response", err)
+
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)
}
-
} else {
-
log.Printf("failed to setup signed client for %s; ignoring...", f.Knot)
}
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
···
DidHandleMap: didHandleMap,
FilteringBy: state,
})
+
return
+
}
+
+
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
f, err := fullyResolvedRepo(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
+
}
+
+
// Get the pull request ID from the request URL
+
pullId := chi.URLParam(r, "pull")
+
pullIdInt, err := strconv.Atoi(pullId)
+
if err != nil {
+
log.Println("failed to parse pull ID:", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
return
+
}
+
+
// Get the patch data from the request body
+
patch := r.FormValue("patch")
+
branch := r.FormValue("targetBranch")
+
+
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
+
}
+
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.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), user.Did, f.RepoName, branch)
+
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 {
+
err := db.MergePull(s.db, f.RepoAt, pullIdInt)
+
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
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pullIdInt))
+
} else {
+
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.")
+
}
+
}
+
+
func (s *State) PullComment(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
+
}
+
+
pullId := chi.URLParam(r, "pull")
+
pullIdInt, err := strconv.Atoi(pullId)
+
if err != nil {
+
http.Error(w, "bad pull id", http.StatusBadRequest)
+
log.Println("failed to parse pull id", err)
+
return
+
}
+
+
switch r.Method {
+
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() // Will be ignored if we commit
+
+
commentId := rand.IntN(1000000)
+
createdAt := time.Now().Format(time.RFC3339)
+
commentIdInt64 := int64(commentId)
+
ownerDid := user.Did
+
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pullIdInt)
+
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, _ := s.auth.AuthorizedClient(r)
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullCommentNSID,
+
Repo: user.Did,
+
Rkey: s.TID(),
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPullComment{
+
Repo: &atUri,
+
Pull: pullAt,
+
CommentId: &commentIdInt64,
+
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
+
err = db.NewPullComment(tx, &db.PullComment{
+
OwnerDid: user.Did,
+
RepoAt: f.RepoAt.String(),
+
CommentId: commentId,
+
PullId: pullIdInt,
+
Body: body,
+
CommentAt: atResp.Uri,
+
})
+
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
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pullIdInt, commentId))
+
return
+
}
+
}
+
+
func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("malformed middleware")
+
return
+
}
+
+
pullId := chi.URLParam(r, "pull")
+
pullIdInt, err := strconv.Atoi(pullId)
+
if err != nil {
+
log.Println("malformed middleware")
+
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
+
}
+
+
// Close the pull in the database
+
err = db.ClosePull(tx, f.RepoAt, pullIdInt)
+
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(), pullIdInt))
+
return
+
}
+
+
func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to resolve repo", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen 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
+
}
+
+
pullId := chi.URLParam(r, "pull")
+
pullIdInt, err := strconv.Atoi(pullId)
+
if err != nil {
+
log.Println("failed to parse pull id", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
+
return
+
}
+
+
// Reopen the pull in the database
+
err = db.ReopenPull(tx, f.RepoAt, pullIdInt)
+
if err != nil {
+
log.Println("failed to reopen pull", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen 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(), pullIdInt))
return
+4 -4
appview/state/router.go
···
r.Get("/new", s.NewPull)
r.Post("/new", s.NewPull)
r.Patch("/{pull}/patch", s.EditPatch)
-
// r.Post("/{pull}/comment", s.PullComment)
-
// r.Post("/{pull}/close", s.ClosePull)
-
// r.Post("/{pull}/reopen", s.ReopenPull)
-
// r.Post("/{pull}/merge", s.MergePull)
+
r.Post("/{pull}/comment", s.PullComment)
+
r.Post("/{pull}/close", s.ClosePull)
+
r.Post("/{pull}/reopen", s.ReopenPull)
+
r.Post("/{pull}/merge", s.MergePull)
})
})
+3 -1
input.css
···
hover:before:shadow-[0_2px_2px_0_rgba(20,20,96,0.1),inset_0_-2px_0_0_#f5f5f5]
focus:outline-none focus-visible:before:outline
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
-
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)];
+
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
+
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
+
disabled:hover:before:bg-white disabled:hover:before:shadow-none;
}
}
@layer utilities {