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

begin work on round-based review

needs frontend bits

Changed files
+988 -892
appview
+49 -9
appview/db/db.go
···
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
);
create table if not exists pulls (
+
-- identifiers
id integer primary key autoincrement,
+
pull_id integer not null,
+
+
-- at identifiers
+
repo_at text not null,
owner_did text not null,
-
repo_at text not null,
-
pull_id integer not null,
+
rkey text not null,
+
pull_at text,
+
+
-- content
title text not null,
body text not null,
-
patch text,
-
pull_at text,
-
rkey text not null,
target_branch text not null,
state integer not null default 0 check (state in (0, 1, 2)), -- open, merged, closed
+
+
-- meta
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
unique(repo_at, pull_id),
foreign key (repo_at) references repos(at_uri) on delete cascade
);
+
+
-- every pull must have atleast 1 submission: the initial submission
+
create table if not exists pull_submissions (
+
-- identifiers
+
id integer primary key autoincrement,
+
pull_id integer not null,
+
+
-- at identifiers
+
repo_at text not null,
+
+
-- content, these are immutable, and require a resubmission to update
+
round_number integer not null default 0,
+
patch text,
+
+
-- meta
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+
+
-- constraints
+
unique(repo_at, pull_id, round_number),
+
foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade
+
);
+
create table if not exists pull_comments (
+
-- identifiers
id integer primary key autoincrement,
-
owner_did text not null,
pull_id integer not null,
+
submission_id integer not null,
+
+
-- at identifiers
repo_at text not null,
-
comment_id integer not null,
+
owner_did text not null,
comment_at text not null,
+
+
-- content
body text not null,
+
+
-- meta
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
-
unique(pull_id, comment_id),
-
foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade
+
+
-- constraints
+
foreign key (repo_at, pull_id) references pulls(repo_at, pull_id) on delete cascade,
+
foreign key (submission_id) references pull_submissions(id) on delete cascade
);
+
create table if not exists _jetstream (
id integer primary key autoincrement,
last_time_us integer not null
+225 -78
appview/db/pulls.go
···
import (
"database/sql"
+
"fmt"
+
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
···
}
type Pull struct {
-
ID int
-
OwnerDid string
-
RepoAt syntax.ATURI
-
PullAt syntax.ATURI
-
TargetBranch string
-
Patch string
-
PullId int
+
// ids
+
ID int
+
PullId int
+
+
// at ids
+
RepoAt syntax.ATURI
+
OwnerDid string
+
Rkey string
+
PullAt syntax.ATURI
+
+
// content
Title string
Body string
+
TargetBranch string
State PullState
-
Created time.Time
-
Rkey string
+
Submissions []*PullSubmission
+
+
// meta
+
Created time.Time
+
}
+
+
type PullSubmission struct {
+
// ids
+
ID int
+
PullId int
+
+
// at ids
+
RepoAt syntax.ATURI
+
+
// content
+
RoundNumber int
+
Patch string
+
Comments []PullComment
+
+
// meta
+
Created time.Time
}
type PullComment struct {
-
ID int
+
// ids
+
ID int
+
PullId int
+
SubmissionId int
+
+
// at ids
+
RepoAt string
OwnerDid string
-
PullId int
-
RepoAt string
-
CommentId int
CommentAt string
-
Body string
-
Created time.Time
+
+
// content
+
Body string
+
+
// meta
+
Created time.Time
+
}
+
+
func (p *Pull) LatestPatch() string {
+
latestSubmission := p.Submissions[len(p.Submissions)-1]
+
return latestSubmission.Patch
}
func NewPull(tx *sql.Tx, pull *Pull) error {
···
pull.State = PullOpen
_, err = tx.Exec(`
-
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)
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state)
+
values (?, ?, ?, ?, ?, ?, ?, ?)
+
`, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Rkey, pull.State)
+
if err != nil {
+
return err
+
}
+
+
_, err = tx.Exec(`
+
insert into pull_submissions (pull_id, repo_at, round_number, patch)
+
values (?, ?, ?, ?)
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch)
if err != nil {
return err
}
···
target_branch,
pull_at,
body,
-
patch,
rkey
from
pulls
···
for rows.Next() {
var pull Pull
var createdAt string
-
err := rows.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.State, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey)
+
err := rows.Scan(
+
&pull.OwnerDid,
+
&pull.PullId,
+
&createdAt,
+
&pull.Title,
+
&pull.State,
+
&pull.TargetBranch,
+
&pull.PullAt,
+
&pull.Body,
+
&pull.Rkey,
+
)
if err != nil {
return nil, err
}
···
}
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
-
query := `select owner_did, created, title, state, target_branch, pull_at, body, patch, rkey from pulls where repo_at = ? and pull_id = ?`
+
query := `
+
select
+
owner_did,
+
pull_id,
+
created,
+
title,
+
state,
+
target_branch,
+
pull_at,
+
repo_at,
+
body,
+
rkey
+
from
+
pulls
+
where
+
repo_at = ? and pull_id = ?
+
`
row := e.QueryRow(query, repoAt, pullId)
var pull Pull
var createdAt string
-
err := row.Scan(&pull.OwnerDid, &createdAt, &pull.Title, &pull.State, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey)
+
err := row.Scan(
+
&pull.OwnerDid,
+
&pull.PullId,
+
&createdAt,
+
&pull.Title,
+
&pull.State,
+
&pull.TargetBranch,
+
&pull.PullAt,
+
&pull.RepoAt,
+
&pull.Body,
+
&pull.Rkey,
+
)
if err != nil {
return nil, err
}
···
}
pull.Created = createdTime
-
return &pull, nil
-
}
-
-
func GetPullWithComments(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, []PullComment, error) {
-
query := `select owner_did, pull_id, created, title, state, target_branch, pull_at, body, patch, rkey from pulls where repo_at = ? and pull_id = ?`
-
row := e.QueryRow(query, repoAt, pullId)
-
-
var pull Pull
-
var createdAt string
-
err := row.Scan(&pull.OwnerDid, &pull.PullId, &createdAt, &pull.Title, &pull.State, &pull.TargetBranch, &pull.PullAt, &pull.Body, &pull.Patch, &pull.Rkey)
+
submissionsQuery := `
+
select
+
id, pull_id, repo_at, round_number, patch, created
+
from
+
pull_submissions
+
where
+
repo_at = ? and pull_id = ?
+
`
+
submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId)
if err != nil {
-
return nil, nil, err
+
return nil, err
}
+
defer submissionsRows.Close()
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
-
if err != nil {
-
return nil, nil, err
-
}
-
pull.Created = createdTime
+
submissionsMap := make(map[int]*PullSubmission)
-
comments, err := GetPullComments(e, repoAt, pullId)
-
if err != nil {
-
return nil, nil, err
-
}
-
-
return &pull, comments, nil
-
}
+
for submissionsRows.Next() {
+
var submission PullSubmission
+
var submissionCreatedStr string
+
err := submissionsRows.Scan(
+
&submission.ID,
+
&submission.PullId,
+
&submission.RepoAt,
+
&submission.RoundNumber,
+
&submission.Patch,
+
&submissionCreatedStr,
+
)
+
if err != nil {
+
return nil, err
+
}
-
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,
-
comment.OwnerDid,
-
comment.RepoAt,
-
comment.CommentAt,
-
comment.PullId,
-
comment.CommentId,
-
comment.Body,
-
)
-
return err
-
}
+
submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr)
+
if err != nil {
+
return nil, err
+
}
+
submission.Created = submissionCreatedTime
-
func GetPullComments(e Execer, repoAt syntax.ATURI, pullId int) ([]PullComment, error) {
-
var comments []PullComment
+
submissionsMap[submission.ID] = &submission
+
}
+
if err = submissionsRows.Close(); err != nil {
+
return nil, err
+
}
+
if len(submissionsMap) == 0 {
+
return &pull, nil
+
}
-
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 []PullComment{}, nil
+
var args []any
+
for k := range submissionsMap {
+
args = append(args, k)
}
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ")
+
commentsQuery := fmt.Sprintf(`
+
select
+
id,
+
pull_id,
+
submission_id,
+
repo_at,
+
owner_did,
+
comment_at,
+
body,
+
created
+
from
+
pull_comments
+
where
+
submission_id IN (%s)
+
order by
+
created asc
+
`, inClause)
+
commentsRows, err := e.Query(commentsQuery, args...)
if err != nil {
return nil, err
}
-
defer rows.Close()
+
defer commentsRows.Close()
-
for rows.Next() {
+
for commentsRows.Next() {
var comment PullComment
-
var createdAt string
-
err := rows.Scan(&comment.OwnerDid, &comment.PullId, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt)
+
var commentCreatedStr string
+
err := commentsRows.Scan(
+
&comment.ID,
+
&comment.PullId,
+
&comment.SubmissionId,
+
&comment.RepoAt,
+
&comment.OwnerDid,
+
&comment.CommentAt,
+
&comment.Body,
+
&commentCreatedStr,
+
)
if err != nil {
return nil, err
}
-
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
+
commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr)
if err != nil {
return nil, err
}
-
comment.Created = createdAtTime
+
comment.Created = commentCreatedTime
+
+
// Add the comment to its submission
+
if submission, ok := submissionsMap[comment.SubmissionId]; ok {
+
submission.Comments = append(submission.Comments, comment)
+
}
+
+
}
+
if err = commentsRows.Err(); err != nil {
+
return nil, err
+
}
+
+
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
+
for _, submission := range submissionsMap {
+
pull.Submissions[submission.RoundNumber] = submission
+
}
+
+
return &pull, nil
+
}
-
comments = append(comments, comment)
+
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
+
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
+
res, err := e.Exec(
+
query,
+
comment.OwnerDid,
+
comment.RepoAt,
+
comment.SubmissionId,
+
comment.CommentAt,
+
comment.PullId,
+
comment.Body,
+
)
+
if err != nil {
+
return 0, err
}
-
if err := rows.Err(); err != nil {
-
return nil, err
+
i, err := res.LastInsertId()
+
if err != nil {
+
return 0, err
}
-
return comments, nil
+
return i, nil
}
func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState PullState) error {
···
return err
}
+
func ResubmitPull(e Execer, pull *Pull, newPatch string) error {
+
newRoundNumber := len(pull.Submissions)
+
_, err := e.Exec(`
+
insert into pull_submissions (pull_id, repo_at, round_number, patch)
+
values (?, ?, ?, ?)
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch)
+
+
return err
+
}
+
type PullCount struct {
Open int
Merged int
···
return count, nil
}
-
-
func EditPatch(e Execer, repoAt syntax.ATURI, pullId int, patch string) error {
-
_, err := e.Exec(`update pulls set patch = ? where repo_at = ? and pull_id = ?`, patch, repoAt, pullId)
-
return err
-
}
+4 -1
appview/pages/funcmap.go
···
},
"timeFmt": humanize.Time,
"byteFmt": humanize.Bytes,
-
"length": func(slice interface{}) int {
+
"length": func(slice any) int {
v := reflect.ValueOf(slice)
if v.Kind() == reflect.Slice || v.Kind() == reflect.Array {
return v.Len()
···
"isNil": func(t any) bool {
// returns false for other "zero" values
return t == nil
+
},
+
"list": func(args ...any) []any {
+
return args
},
}
}
+7 -8
appview/pages/pages.go
···
}
type RepoSinglePullParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
DidHandleMap map[string]string
-
Pull db.Pull
-
PullOwnerHandle string
-
Comments []db.PullComment
-
Active string
-
MergeCheck types.MergeCheckResponse
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
DidHandleMap map[string]string
+
+
Pull db.Pull
+
MergeCheck types.MergeCheckResponse
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
+92 -165
appview/pages/templates/repo/pulls/pull.html
···
{{ define "title" }}
-
{{ .Pull.Title }} · pull #{{ .Pull.PullId }} ·
-
{{ .RepoInfo.FullName }}
+
{{ .Pull.Title }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
{{ end }}
{{ define "repoContent" }}
···
{{ $bgColor = "bg-purple-600" }}
{{ $icon = "git-merge" }}
{{ end }}
-
<section>
<div class="flex items-center gap-2">
···
{{ end }}
</section>
-
<div class="flex flex-col justify-end mt-4">
-
<details>
-
<summary
-
class="list-none cursor-pointer sticky top-0 bg-white rounded-sm px-3 py-2 border border-gray-200 flex items-center text-gray-700 hover:bg-gray-50 transition-colors mt-auto"
-
>
-
<i data-lucide="code" class="w-4 h-4 mr-2"></i>
-
<span>patch</span>
-
</summary>
-
<div class="relative">
-
<pre
-
id="patch-preview"
-
class="font-mono overflow-x-scroll bg-gray-50 p-4 rounded-b border border-gray-200 text-sm"
-
>
-
{{- .Pull.Patch -}}
-
</pre
-
>
-
<form
-
id="patch-form"
-
hx-patch="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/patch"
-
hx-swap="none"
-
>
-
<textarea
-
id="patch"
-
name="patch"
-
class="font-mono w-full h-full p-4 rounded-b border border-gray-200 text-sm hidden"
-
>{{- .Pull.Patch -}}</textarea>
-
-
<div class="flex gap-2 justify-end mt-2">
-
<button
-
id="edit-patch-btn"
-
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>
-
<button
-
id="save-patch-btn"
-
type="submit"
-
class="btn btn-sm bg-green-500 hidden"
-
>
-
<i data-lucide="save" class="w-4 h-4 mr-1"></i>Save
-
</button>
-
<button
-
id="cancel-patch-btn"
-
type="button"
-
class="btn btn-sm bg-gray-300 hidden"
-
onclick="togglePatchEdit(false)"
-
>
-
Cancel
-
</button>
-
</div>
-
</form>
-
-
<div id="pull-error" class="error"></div>
-
<div id="pull-success" class="success"></div>
-
</div>
-
<script>
-
function togglePatchEdit(editMode) {
-
const preview = document.getElementById("patch-preview");
-
const editor = document.getElementById("patch");
-
const editBtn = document.getElementById("edit-patch-btn");
-
const saveBtn = document.getElementById("save-patch-btn");
-
const cancelBtn =
-
document.getElementById("cancel-patch-btn");
-
-
if (editMode) {
-
preview.classList.add("hidden");
-
editor.classList.remove("hidden");
-
editBtn.classList.add("hidden");
-
saveBtn.classList.remove("hidden");
-
cancelBtn.classList.remove("hidden");
-
} else {
-
preview.classList.remove("hidden");
-
editor.classList.add("hidden");
-
editBtn.classList.remove("hidden");
-
saveBtn.classList.add("hidden");
-
cancelBtn.classList.add("hidden");
-
}
-
}
-
-
document
-
.getElementById("save-patch-btn")
-
.addEventListener("click", function () {
-
togglePatchEdit(false);
-
});
-
</script>
-
</details>
-
</div>
{{ end }}
{{ define "repoAfter" }}
+
<section id="submissions">
+
{{ block "submissions" . }} {{ end }}
+
</section>
+
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
-
<section id="comments" class="mt-8 space-y-4 relative">
-
{{ block "comments" . }} {{ end }}
+
{{ if $isPullAuthor }}
+
<section id="update-card" class="mt-8 space-y-4 relative">
+
{{ block "resubmitCard" . }} {{ end }}
+
</section>
+
{{ end }}
+
<section id="merge-card" class="mt-8 space-y-4 relative">
{{ if .Pull.State.IsMerged }}
{{ block "alreadyMergedCard" . }} {{ end }}
{{ else if .MergeCheck }}
···
{{ end }}
{{ end }}
</section>
-
-
{{ block "newComment" . }} {{ end }}
{{ if and (or $isPullAuthor $isPushAllowed) (not .Pull.State.IsMerged) }}
{{ $action := "close" }}
···
<div id="pull-reopen"></div>
{{ end }}
-
{{ define "comments" }}
-
{{ range $index, $comment := .Comments }}
-
<div
-
id="comment-{{ .CommentId }}"
-
class="rounded bg-white p-4 relative drop-shadow-sm"
-
>
-
{{ if eq $index 0 }}
-
<div
-
class="absolute left-8 -top-8 w-px h-8 bg-gray-300"
-
></div>
-
{{ else }}
-
<div
-
class="absolute left-8 -top-4 w-px h-4 bg-gray-300"
-
></div>
-
{{ end }}
-
<div class="flex items-center gap-2 mb-2 text-gray-400">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
-
<span class="text-sm">
-
<a
-
href="/{{ $owner }}"
-
class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
</span>
-
<span
-
class="px-1 select-none before:content-['\00B7']"
-
></span>
-
<a
-
href="#{{ .CommentId }}"
-
class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline"
-
id="{{ .CommentId }}"
-
>
-
{{ .Created | timeFmt }}
-
</a>
-
</div>
-
<div class="prose">
-
{{ .Body | markdown }}
+
{{ define "submissions" }}
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
+
{{ range $idx, $item := .Pull.Submissions }}
+
{{ with $item }}
+
<details {{ if eq $idx $lastIdx }}open{{ end }}>
+
<summary>round #{{ .RoundNumber }}, {{ .Created | timeFmt }}, received {{ len .Comments }} comments</summary>
+
<div>
+
<h2>patch submitted by {{index $.DidHandleMap $.Pull.OwnerDid}}</h2>
+
<pre><code>{{- .Patch -}}</code></pre>
+
+
{{ range .Comments }}
+
<div id="comment-{{.ID}}">
+
{{ index $.DidHandleMap .OwnerDid }} commented {{ .Created | timeFmt }}: {{ .Body }}
</div>
+
{{ end }}
+
{{ block "newComment" (list $ .ID) }} {{ end }}
</div>
+
</details>
+
{{ end }}
{{ end }}
{{ end }}
{{ define "newComment" }}
-
{{ if .LoggedInUser }}
-
<form
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/comment"
-
class="mt-8"
-
hx-swap="none">
-
<textarea
-
name="body"
-
class="w-full p-2 rounded border border-gray-200"
-
placeholder="Add to the discussion..."
-
></textarea>
-
<button type="submit" class="btn mt-2">comment</button>
-
<div id="pull-comment"></div>
-
</form>
-
{{ else }}
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8">
-
<a href="/login" class="underline">login</a> to join the discussion
-
</div>
+
{{ $rootObj := index . 0 }}
+
{{ $submissionId := index . 1 }}
+
+
{{ with $rootObj }}
+
{{ if .LoggedInUser }}
+
<form
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/comment"
+
class="mt-8"
+
hx-swap="none">
+
<input type="hidden" name="submissionId" value="{{ $submissionId }}">
+
<textarea
+
name="body"
+
class="w-full p-2 rounded border border-gray-200"
+
placeholder="Add to the discussion..."
+
></textarea>
+
<button type="submit" class="btn mt-2">comment</button>
+
<div id="pull-comment"></div>
+
</form>
+
{{ else }}
+
<div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8">
+
<a href="/login" class="underline">login</a> to join the discussion
+
</div>
+
{{ end }}
{{ end }}
{{ end }}
···
<div
id="merge-status-card"
class="rounded relative border bg-red-50 border-red-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-red-500">
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
···
<div
id="merge-status-card"
class="rounded relative border bg-green-50 border-green-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-green-500">
<i data-lucide="check-circle" class="w-4 h-4"></i>
···
{{ if or .Pull.State.IsClosed .MergeCheck.IsConflicted }}
disabled
{{ end }}>
-
<i data-lucide="git-merge" class="w-4 h-4 text-purple-500"></i>
+
<i data-lucide="git-merge" class="w-4 h-4"></i>
<span>merge</span>
</button>
{{ end }}
···
</div>
</div>
{{ end }}
+
+
{{ define "resubmitCard" }}
+
<div
+
id="resubmit-pull-card"
+
class="rounded relative border bg-amber-50 border-amber-200 p-4">
+
+
<div class="flex items-center gap-2 text-amber-500">
+
<i data-lucide="edit" class="w-4 h-4"></i>
+
<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 reviews if any.
+
This begins a new round of reviews,
+
you can still view your previous submissions and reviews.
+
</div>
+
+
<div class="mt-4 flex items-center gap-2">
+
<form hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" class="w-full">
+
<textarea
+
name="patch"
+
class="w-full p-2 rounded border border-gray-200"
+
placeholder="Enter new patch"
+
></textarea>
+
<button
+
type="submit"
+
class="btn flex items-center gap-2"
+
{{ if or .Pull.State.IsClosed }}
+
disabled
+
{{ end }}>
+
<i data-lucide="refresh-ccw" class="w-4 h-4"></i>
+
<span>resubmit</span>
+
</button>
+
</form>
+
+
<div id="resubmit-error" class="error"></div>
+
<div id="resubmit-success" class="success"></div>
+
</div>
+
</div>
+
{{ end }}
+1 -2
appview/state/middleware.go
···
return
}
-
pr, comments, err := db.GetPullWithComments(s.db, f.RepoAt, prIdInt)
+
pr, err := db.GetPull(s.db, f.RepoAt, prIdInt)
if err != nil {
log.Println("failed to get pull and comments", err)
return
}
ctx := context.WithValue(r.Context(), "pull", pr)
-
ctx = context.WithValue(ctx, "pull_comments", comments)
next.ServeHTTP(w, r.WithContext(ctx))
})
+609
appview/state/pull.go
···
+
package state
+
+
import (
+
"encoding/json"
+
"fmt"
+
"io"
+
"log"
+
"net/http"
+
"strconv"
+
"time"
+
+
"github.com/sotangled/tangled/api/tangled"
+
"github.com/sotangled/tangled/appview/db"
+
"github.com/sotangled/tangled/appview/pages"
+
"github.com/sotangled/tangled/types"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
)
+
+
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
+
}
+
+
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
+
}
+
+
totalIdents := 1
+
for _, submission := range pull.Submissions {
+
totalIdents += len(submission.Comments)
+
}
+
+
identsToResolve := make([]string, totalIdents)
+
+
// populate idents
+
identsToResolve[0] = pull.OwnerDid
+
idx := 1
+
for _, submission := range pull.Submissions {
+
for _, comment := range submission.Comments {
+
identsToResolve[idx] = comment.OwnerDid
+
idx += 1
+
}
+
}
+
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
+
didHandleMap := make(map[string]string)
+
for _, identity := range resolvedIds {
+
if !identity.Handle.IsInvalidHandle() {
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
+
} else {
+
didHandleMap[identity.DID.String()] = identity.DID.String()
+
}
+
}
+
+
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)
+
}
+
}
+
+
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Pull: *pull,
+
MergeCheck: mergeCheckResponse,
+
})
+
}
+
+
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
params := r.URL.Query()
+
+
state := db.PullOpen
+
switch params.Get("state") {
+
case "closed":
+
state = db.PullClosed
+
case "merged":
+
state = db.PullMerged
+
}
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
pulls, err := db.GetPulls(s.db, f.RepoAt, state)
+
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,
+
FilteringBy: state,
+
})
+
return
+
}
+
+
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
+
}
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
switch r.Method {
+
case http.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
+
}
+
+
// Start a transaction
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
return
+
}
+
defer tx.Rollback()
+
+
createdAt := time.Now().Format(time.RFC3339)
+
ownerDid := user.Did
+
+
pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
+
if err != nil {
+
log.Println("failed to get pull at", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
return
+
}
+
+
atUri := f.RepoAt.String()
+
client, _ := 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,
+
Owner: &ownerDid,
+
Body: &body,
+
CreatedAt: &createdAt,
+
},
+
},
+
})
+
if err != nil {
+
log.Println("failed to create pull comment", err)
+
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
+
return
+
}
+
+
// Create the pull comment in the database with the commentAt field
+
commentId, err := db.NewPullComment(tx, &db.PullComment{
+
OwnerDid: user.Did,
+
RepoAt: f.RepoAt.String(),
+
PullId: pull.PullId,
+
Body: body,
+
CommentAt: atResp.Uri,
+
SubmissionId: submissionId,
+
})
+
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(), pull.PullId, commentId))
+
return
+
}
+
}
+
+
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:
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client for %s", f.Knot)
+
s.pages.Error503(w)
+
return
+
}
+
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
+
if err != nil {
+
log.Println("failed to reach knotserver", err)
+
return
+
}
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
log.Printf("Error reading response body: %v", err)
+
return
+
}
+
+
var result types.RepoBranchesResponse
+
err = json.Unmarshal(body, &result)
+
if err != nil {
+
log.Println("failed to parse response:", err)
+
return
+
}
+
+
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
Branches: result.Branches,
+
})
+
case http.MethodPost:
+
title := r.FormValue("title")
+
body := r.FormValue("body")
+
targetBranch := r.FormValue("targetBranch")
+
patch := r.FormValue("patch")
+
+
if title == "" || body == "" || patch == "" || targetBranch == "" {
+
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 tx.Rollback()
+
+
rkey := s.TID()
+
initialSubmission := db.PullSubmission{
+
Patch: patch,
+
}
+
err = db.NewPull(tx, &db.Pull{
+
Title: title,
+
Body: body,
+
TargetBranch: targetBranch,
+
OwnerDid: user.Did,
+
RepoAt: f.RepoAt,
+
Rkey: rkey,
+
Submissions: []*db.PullSubmission{
+
&initialSubmission,
+
},
+
})
+
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.NextPullId(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: rkey,
+
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) ResubmitPull(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
+
}
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
switch r.Method {
+
case http.MethodPost:
+
patch := r.FormValue("patch")
+
+
if patch == "" {
+
s.pages.Notice(w, "resubmit-error", "Patch is empty.")
+
return
+
}
+
+
if patch == pull.LatestPatch() {
+
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
+
return
+
}
+
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start tx")
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
err = db.ResubmitPull(tx, pull, patch)
+
if err != nil {
+
log.Println("failed to create pull request", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
+
return
+
}
+
client, _ := s.auth.AuthorizedClient(r)
+
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
+
if err != nil {
+
// failed to get record
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
+
return
+
}
+
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.RepoPullNSID,
+
Repo: user.Did,
+
Rkey: pull.Rkey,
+
SwapRecord: ex.Cid,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.RepoPull{
+
Title: pull.Title,
+
PullId: int64(pull.PullId),
+
TargetRepo: string(f.RepoAt),
+
TargetBranch: pull.TargetBranch,
+
Patch: patch, // new patch
+
},
+
},
+
})
+
if err != nil {
+
log.Println("failed to update record", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
+
return
+
}
+
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
return
+
}
+
}
+
+
func (s *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
+
}
+
+
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
+
}
+
+
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(pull.LatestPatch()), user.Did, f.RepoName, pull.TargetBranch)
+
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, pull.PullId)
+
if err != nil {
+
log.Printf("failed to update pull request status in database: %s", err)
+
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+
return
+
}
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
+
} 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) ClosePull(w http.ResponseWriter, r *http.Request) {
+
user := s.auth.GetUser(r)
+
+
f, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("malformed middleware")
+
return
+
}
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
// auth filter: only owner or collaborators can close
+
roles := RolesInRepo(s, user, f)
+
isCollaborator := roles.IsCollaborator()
+
isPullAuthor := user.Did == pull.OwnerDid
+
isCloseAllowed := isCollaborator || isPullAuthor
+
if !isCloseAllowed {
+
log.Println("failed to close pull")
+
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
+
return
+
}
+
+
// Start a transaction
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
return
+
}
+
+
// Close the pull in the database
+
err = db.ClosePull(tx, f.RepoAt, pull.PullId)
+
if err != nil {
+
log.Println("failed to close pull", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
return
+
}
+
+
// Commit the transaction
+
if err = tx.Commit(); err != nil {
+
log.Println("failed to commit transaction", err)
+
s.pages.Notice(w, "pull-close", "Failed to close pull.")
+
return
+
}
+
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
+
return
+
}
+
+
func (s *State) ReopenPull(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-reopen", "Failed to reopen pull.")
+
return
+
}
+
+
pull, ok := r.Context().Value("pull").(*db.Pull)
+
if !ok {
+
log.Println("failed to get pull")
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
+
return
+
}
+
+
// auth filter: only owner or collaborators can close
+
roles := RolesInRepo(s, user, f)
+
isCollaborator := roles.IsCollaborator()
+
isPullAuthor := user.Did == pull.OwnerDid
+
isCloseAllowed := isCollaborator || isPullAuthor
+
if !isCloseAllowed {
+
log.Println("failed to close pull")
+
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
+
return
+
}
+
+
// Start a transaction
+
tx, err := s.db.BeginTx(r.Context(), nil)
+
if err != nil {
+
log.Println("failed to start transaction", err)
+
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
+
return
+
}
+
+
// Reopen the pull in the database
+
err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
+
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(), pull.PullId))
+
return
+
}
-628
appview/state/repo.go
···
}
}
-
func (s *State) EditPatch(w http.ResponseWriter, r *http.Request) {
-
user := s.auth.GetUser(r)
-
-
patch := r.FormValue("patch")
-
if patch == "" {
-
s.pages.Notice(w, "pull-error", "Patch is required.")
-
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
-
}
-
-
if pull.OwnerDid != user.Did {
-
log.Println("failed to edit pull information")
-
s.pages.Notice(w, "pull-error", "Unauthorized")
-
return
-
}
-
-
f, err := fullyResolvedRepo(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// Start a transaction for database operations
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// Set up deferred rollback that will be overridden by commit if successful
-
defer tx.Rollback()
-
-
// Update patch in the database within transaction
-
err = db.EditPatch(tx, f.RepoAt, pull.PullId, patch)
-
if err != nil {
-
log.Println("failed to update patch", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// Update the atproto record
-
client, _ := s.auth.AuthorizedClient(r)
-
pullAt := pull.PullAt
-
-
// Get the existing record first
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pullAt.RecordKey().String())
-
if err != nil {
-
log.Println("failed to get existing pull record", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// Update the record
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.RepoPullNSID,
-
Repo: user.Did,
-
Rkey: pullAt.RecordKey().String(),
-
SwapRecord: ex.Cid,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.RepoPull{
-
Title: pull.Title,
-
PullId: int64(pull.PullId),
-
TargetRepo: string(f.RepoAt),
-
TargetBranch: pull.TargetBranch,
-
Patch: patch,
-
},
-
},
-
})
-
-
if err != nil {
-
log.Println("failed to update pull record in atproto", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// Commit the transaction now that both operations have succeeded
-
err = tx.Commit()
-
if err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
targetBranch := pull.TargetBranch
-
-
// Perform merge check
-
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-success", "Patch updated successfully, but couldn't check mergeability.")
-
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-success", "Patch updated successfully, but couldn't check mergeability.")
-
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-success", "Patch updated successfully, but couldn't check mergeability.")
-
return
-
}
-
-
respBody, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Println("failed to read knotserver response body")
-
s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
-
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-success", "Patch updated successfully, but couldn't check mergeability.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
-
return
-
}
-
-
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:
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
-
if err != nil {
-
log.Printf("failed to create unsigned client for %s", f.Knot)
-
s.pages.Error503(w)
-
return
-
}
-
-
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
-
if err != nil {
-
log.Println("failed to reach knotserver", err)
-
return
-
}
-
-
body, err := io.ReadAll(resp.Body)
-
if err != nil {
-
log.Printf("Error reading response body: %v", err)
-
return
-
}
-
-
var result types.RepoBranchesResponse
-
err = json.Unmarshal(body, &result)
-
if err != nil {
-
log.Println("failed to parse response:", err)
-
return
-
}
-
-
s.pages.RepoNewPull(w, pages.RepoNewPullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
Branches: result.Branches,
-
})
-
case http.MethodPost:
-
title := r.FormValue("title")
-
body := r.FormValue("body")
-
targetBranch := r.FormValue("targetBranch")
-
patch := r.FormValue("patch")
-
-
if title == "" || body == "" || patch == "" || targetBranch == "" {
-
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.NextPullId(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
-
}
-
-
pull, ok1 := r.Context().Value("pull").(*db.Pull)
-
comments, ok2 := r.Context().Value("pull_comments").([]db.PullComment)
-
if !ok1 || !ok2 {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
identsToResolve := make([]string, len(comments))
-
for i, comment := range comments {
-
identsToResolve[i] = comment.OwnerDid
-
}
-
identsToResolve = append(identsToResolve, 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()
-
}
-
}
-
-
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.Patch), 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)
-
}
-
}
-
-
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
Pull: *pull,
-
Comments: comments,
-
DidHandleMap: didHandleMap,
-
MergeCheck: mergeCheckResponse,
-
})
-
}
-
func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
if err != nil {
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
return
-
}
-
-
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
-
user := s.auth.GetUser(r)
-
params := r.URL.Query()
-
-
state := db.PullOpen
-
switch params.Get("state") {
-
case "closed":
-
state = db.PullClosed
-
case "merged":
-
state = db.PullMerged
-
}
-
-
f, err := fullyResolvedRepo(r)
-
if err != nil {
-
log.Println("failed to get repo and knot", err)
-
return
-
}
-
-
pulls, err := db.GetPulls(s.db, f.RepoAt, state)
-
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,
-
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
-
}
-
-
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
-
}
-
-
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(pull.Patch), user.Did, f.RepoName, pull.TargetBranch)
-
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, pull.PullId)
-
if err != nil {
-
log.Printf("failed to update pull request status in database: %s", err)
-
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
-
return
-
}
-
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
-
} 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) {
-
user := s.auth.GetUser(r)
-
-
f, err := fullyResolvedRepo(r)
-
if err != nil {
-
log.Println("malformed middleware")
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// auth filter: only owner or collaborators can close
-
roles := RolesInRepo(s, user, f)
-
isCollaborator := roles.IsCollaborator()
-
isPullAuthor := user.Did == pull.OwnerDid
-
isCloseAllowed := isCollaborator || isPullAuthor
-
if !isCloseAllowed {
-
log.Println("failed to close pull")
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
-
return
-
}
-
-
// Start a transaction
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
-
// Close the pull in the database
-
err = db.ClosePull(tx, f.RepoAt, pull.PullId)
-
if err != nil {
-
log.Println("failed to close pull", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
-
// Commit the transaction
-
if err = tx.Commit(); err != nil {
-
log.Println("failed to commit transaction", err)
-
s.pages.Notice(w, "pull-close", "Failed to close pull.")
-
return
-
}
-
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
-
return
-
}
-
-
func (s *State) ReopenPull(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-reopen", "Failed to reopen pull.")
-
return
-
}
-
-
pull, ok := r.Context().Value("pull").(*db.Pull)
-
if !ok {
-
log.Println("failed to get pull")
-
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
-
return
-
}
-
-
// auth filter: only owner or collaborators can close
-
roles := RolesInRepo(s, user, f)
-
isCollaborator := roles.IsCollaborator()
-
isPullAuthor := user.Did == pull.OwnerDid
-
isCloseAllowed := isCollaborator || isPullAuthor
-
if !isCloseAllowed {
-
log.Println("failed to close pull")
-
s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
-
return
-
}
-
-
// Start a transaction
-
tx, err := s.db.BeginTx(r.Context(), nil)
-
if err != nil {
-
log.Println("failed to start transaction", err)
-
s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
-
return
-
}
-
-
// Reopen the pull in the database
-
err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
-
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(), pull.PullId))
-
return
func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
+1 -1
appview/state/router.go
···
// authorized requests below this point
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware(s))
-
r.Patch("/patch", s.EditPatch)
+
r.Post("/resubmit", s.ResubmitPull)
r.Post("/comment", s.PullComment)
r.Post("/close", s.ClosePull)
r.Post("/reopen", s.ReopenPull)