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

Compare changes

Choose any two refs to compare.

+35 -20
appview/db/issues.go
···
"time"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/appview/pagination"
)
type Issue struct {
···
return ownerDid, err
}
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
var issues []Issue
openValue := 0
if isOpen {
···
}
rows, err := e.Query(
-
`select
-
i.owner_did,
-
i.issue_id,
-
i.created,
-
i.title,
-
i.body,
-
i.open,
-
count(c.id)
-
from
-
issues i
-
left join
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
-
where
-
i.repo_at = ? and i.open = ?
-
group by
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
-
order by
-
i.created desc`,
-
repoAt, openValue)
+
`
+
with numbered_issue as (
+
select
+
i.owner_did,
+
i.issue_id,
+
i.created,
+
i.title,
+
i.body,
+
i.open,
+
count(c.id) as comment_count,
+
row_number() over (order by i.created desc) as row_num
+
from
+
issues i
+
left join
+
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
+
where
+
i.repo_at = ? and i.open = ?
+
group by
+
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
+
)
+
select
+
owner_did,
+
issue_id,
+
created,
+
title,
+
body,
+
open,
+
comment_count
+
from
+
numbered_issue
+
where
+
row_num between ? and ?`,
+
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
if err != nil {
return nil, err
}
+54 -2
appview/db/pulls.go
···
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/bluesky-social/indigo/atproto/syntax"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
)
···
return false
}
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
patch := s.Patch
-
diffs, _, err := gitdiff.Parse(strings.NewReader(patch))
+
// if format-patch; then extract each patch
+
var diffs []*gitdiff.File
+
if patchutil.IsFormatPatch(patch) {
+
patches, err := patchutil.ExtractPatches(patch)
+
if err != nil {
+
return nil, err
+
}
+
var ps [][]*gitdiff.File
+
for _, p := range patches {
+
ps = append(ps, p.Files)
+
}
+
+
diffs = patchutil.CombineDiff(ps...)
+
} else {
+
d, _, err := gitdiff.Parse(strings.NewReader(patch))
+
if err != nil {
+
return nil, err
+
}
+
diffs = d
+
}
+
+
return diffs, nil
+
}
+
+
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
+
diffs, err := s.AsDiff(targetBranch)
if err != nil {
log.Println(err)
}
···
nd.Stat.FilesChanged = len(diffs)
return nd
+
}
+
+
func (s PullSubmission) IsFormatPatch() bool {
+
return patchutil.IsFormatPatch(s.Patch)
+
}
+
+
func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch {
+
patches, err := patchutil.ExtractPatches(s.Patch)
+
if err != nil {
+
log.Println("error extracting patches from submission:", err)
+
return []patchutil.FormatPatch{}
+
}
+
+
return patches
}
func NewPull(tx *sql.Tx, pull *Pull) error {
···
}
if err = commentsRows.Err(); err != nil {
return nil, err
+
}
+
+
var pullSourceRepo *Repo
+
if pull.PullSource != nil {
+
if pull.PullSource.RepoAt != nil {
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
+
if err != nil {
+
log.Printf("failed to get repo by at uri: %v", err)
+
} else {
+
pull.PullSource.Repo = pullSourceRepo
+
}
+
}
}
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
+62
appview/filetree/filetree.go
···
+
package filetree
+
+
import (
+
"path/filepath"
+
"sort"
+
"strings"
+
)
+
+
type FileTreeNode struct {
+
Name string
+
Path string
+
IsDirectory bool
+
Children map[string]*FileTreeNode
+
}
+
+
// NewNode creates a new node
+
func newNode(name, path string, isDir bool) *FileTreeNode {
+
return &FileTreeNode{
+
Name: name,
+
Path: path,
+
IsDirectory: isDir,
+
Children: make(map[string]*FileTreeNode),
+
}
+
}
+
+
func FileTree(files []string) *FileTreeNode {
+
rootNode := newNode("", "", true)
+
+
sort.Strings(files)
+
+
for _, file := range files {
+
if file == "" {
+
continue
+
}
+
+
parts := strings.Split(filepath.Clean(file), "/")
+
if len(parts) == 0 {
+
continue
+
}
+
+
currentNode := rootNode
+
currentPath := ""
+
+
for i, part := range parts {
+
if currentPath == "" {
+
currentPath = part
+
} else {
+
currentPath = filepath.Join(currentPath, part)
+
}
+
+
isDir := i < len(parts)-1
+
+
if _, exists := currentNode.Children[part]; !exists {
+
currentNode.Children[part] = newNode(part, currentPath, isDir)
+
}
+
+
currentNode = currentNode.Children[part]
+
}
+
}
+
+
return rootNode
+
}
+126
appview/middleware/middleware.go
···
+
package middleware
+
+
import (
+
"context"
+
"log"
+
"net/http"
+
"strconv"
+
"time"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/xrpc"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/pagination"
+
)
+
+
type Middleware func(http.Handler) http.Handler
+
+
func AuthMiddleware(a *auth.Auth) Middleware {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
+
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
+
}
+
if r.Header.Get("HX-Request") == "true" {
+
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
+
w.Header().Set("HX-Redirect", "/login")
+
w.WriteHeader(http.StatusOK)
+
}
+
}
+
+
session, err := a.GetSession(r)
+
if session.IsNew || err != nil {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
+
if !ok || !authorized {
+
log.Printf("not logged in, redirecting")
+
redirectFunc(w, r)
+
return
+
}
+
+
// refresh if nearing expiry
+
// TODO: dedup with /login
+
expiryStr := session.Values[appview.SessionExpiry].(string)
+
expiry, err := time.Parse(time.RFC3339, expiryStr)
+
if err != nil {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
+
did, ok2 := session.Values[appview.SessionDid].(string)
+
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
+
+
if !ok1 || !ok2 || !ok3 {
+
log.Println("invalid expiry time", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
if time.Now().After(expiry) {
+
log.Println("token expired, refreshing ...")
+
+
client := xrpc.Client{
+
Host: pdsUrl,
+
Auth: &xrpc.AuthInfo{
+
Did: did,
+
AccessJwt: refreshJwt,
+
RefreshJwt: refreshJwt,
+
},
+
}
+
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
+
if err != nil {
+
log.Println("failed to refresh session", err)
+
redirectFunc(w, r)
+
return
+
}
+
+
sessionish := auth.RefreshSessionWrapper{atSession}
+
+
err = a.StoreSession(r, w, &sessionish, pdsUrl)
+
if err != nil {
+
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
+
return
+
}
+
+
log.Println("successfully refreshed token")
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+
+
func Paginate(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
page := pagination.FirstPage()
+
+
offsetVal := r.URL.Query().Get("offset")
+
if offsetVal != "" {
+
offset, err := strconv.Atoi(offsetVal)
+
if err != nil {
+
log.Println("invalid offset")
+
} else {
+
page.Offset = offset
+
}
+
}
+
+
limitVal := r.URL.Query().Get("limit")
+
if limitVal != "" {
+
limit, err := strconv.Atoi(limitVal)
+
if err != nil {
+
log.Println("invalid limit")
+
} else {
+
page.Limit = limit
+
}
+
}
+
+
ctx := context.WithValue(r.Context(), "page", page)
+
next.ServeHTTP(w, r.WithContext(ctx))
+
})
+
}
+2
appview/pages/funcmap.go
···
"time"
"github.com/dustin/go-humanize"
+
"tangled.sh/tangled.sh/core/appview/filetree"
"tangled.sh/tangled.sh/core/appview/pages/markup"
)
···
return template.HTML(data)
},
"cssContentHash": CssContentHash,
+
"fileTree": filetree.FileTree,
}
}
+157 -50
appview/pages/pages.go
···
"io/fs"
"log"
"net/http"
+
"os"
"path"
"path/filepath"
"slices"
"strings"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/appview/pagination"
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
+
"tangled.sh/tangled.sh/core/patchutil"
+
"tangled.sh/tangled.sh/core/types"
+
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/microcosm-cc/bluemonday"
-
"tangled.sh/tangled.sh/core/appview/auth"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
-
"tangled.sh/tangled.sh/core/types"
)
//go:embed templates/* static
var Files embed.FS
type Pages struct {
-
t map[string]*template.Template
+
t map[string]*template.Template
+
dev bool
+
embedFS embed.FS
+
templateDir string // Path to templates on disk for dev mode
}
-
func NewPages() *Pages {
-
templates := make(map[string]*template.Template)
+
func NewPages(dev bool) *Pages {
+
p := &Pages{
+
t: make(map[string]*template.Template),
+
dev: dev,
+
embedFS: Files,
+
templateDir: "appview/pages",
+
}
+
// Initial load of all templates
+
p.loadAllTemplates()
+
+
return p
+
}
+
+
func (p *Pages) loadAllTemplates() {
+
templates := make(map[string]*template.Template)
var fragmentPaths []string
+
+
// Use embedded FS for initial loading
// First, collect all fragment paths
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
-
if d.IsDir() {
return nil
}
-
if !strings.HasSuffix(path, ".html") {
return nil
}
-
if !strings.Contains(path, "fragments/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
name = strings.TrimSuffix(name, ".html")
-
tmpl, err := template.New(name).
Funcs(funcMap()).
-
ParseFS(Files, path)
+
ParseFS(p.embedFS, path)
if err != nil {
log.Fatalf("setting up fragment: %v", err)
}
-
templates[name] = tmpl
fragmentPaths = append(fragmentPaths, path)
log.Printf("loaded fragment: %s", name)
···
}
// Then walk through and setup the rest of the templates
-
err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
-
if d.IsDir() {
return nil
}
-
if !strings.HasSuffix(path, "html") {
return nil
}
-
// Skip fragments as they've already been loaded
if strings.Contains(path, "fragments/") {
return nil
}
-
// Skip layouts
if strings.Contains(path, "layouts/") {
return nil
}
-
name := strings.TrimPrefix(path, "templates/")
name = strings.TrimSuffix(name, ".html")
-
// Add the page template on top of the base
allPaths := []string{}
allPaths = append(allPaths, "templates/layouts/*.html")
···
allPaths = append(allPaths, path)
tmpl, err := template.New(name).
Funcs(funcMap()).
-
ParseFS(Files, allPaths...)
+
ParseFS(p.embedFS, allPaths...)
if err != nil {
return fmt.Errorf("setting up template: %w", err)
}
-
templates[name] = tmpl
log.Printf("loaded template: %s", name)
return nil
···
}
log.Printf("total templates loaded: %d", len(templates))
+
p.t = templates
+
}
-
return &Pages{
-
t: templates,
+
// loadTemplateFromDisk loads a template from the filesystem in dev mode
+
func (p *Pages) loadTemplateFromDisk(name string) error {
+
if !p.dev {
+
return nil
+
}
+
+
log.Printf("reloading template from disk: %s", name)
+
+
// Find all fragments first
+
var fragmentPaths []string
+
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
+
if err != nil {
+
return err
+
}
+
if d.IsDir() {
+
return nil
+
}
+
if !strings.HasSuffix(path, ".html") {
+
return nil
+
}
+
if !strings.Contains(path, "fragments/") {
+
return nil
+
}
+
fragmentPaths = append(fragmentPaths, path)
+
return nil
+
})
+
if err != nil {
+
return fmt.Errorf("walking disk template dir for fragments: %w", err)
+
}
+
+
// Find the template path on disk
+
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
+
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
+
return fmt.Errorf("template not found on disk: %s", name)
+
}
+
+
// Create a new template
+
tmpl := template.New(name).Funcs(funcMap())
+
+
// Parse layouts
+
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
+
layouts, err := filepath.Glob(layoutGlob)
+
if err != nil {
+
return fmt.Errorf("finding layout templates: %w", err)
}
+
+
// Create paths for parsing
+
allFiles := append(layouts, fragmentPaths...)
+
allFiles = append(allFiles, templatePath)
+
+
// Parse all templates
+
tmpl, err = tmpl.ParseFiles(allFiles...)
+
if err != nil {
+
return fmt.Errorf("parsing template files: %w", err)
+
}
+
+
// Update the template in the map
+
p.t[name] = tmpl
+
log.Printf("template reloaded from disk: %s", name)
+
return nil
}
-
type LoginParams struct {
+
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
+
// In dev mode, reload the template from disk before executing
+
if p.dev {
+
if err := p.loadTemplateFromDisk(templateName); err != nil {
+
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
+
// Continue with the existing template
+
}
+
}
+
+
tmpl, exists := p.t[templateName]
+
if !exists {
+
return fmt.Errorf("template not found: %s", templateName)
+
}
+
+
if base == "" {
+
return tmpl.Execute(w, params)
+
} else {
+
return tmpl.ExecuteTemplate(w, base, params)
+
}
}
func (p *Pages) execute(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
+
return p.executeOrReload(name, w, "layouts/base", params)
}
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
-
return p.t[name].Execute(w, params)
+
return p.executeOrReload(name, w, "", params)
}
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
+
return p.executeOrReload(name, w, "layouts/repobase", params)
+
}
+
+
type LoginParams struct {
}
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
}
type RepoCommitParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
EmailToDidOrHandle map[string]string
+
types.RepoCommitResponse
-
EmailToDidOrHandle map[string]string
}
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
}
type RepoIssuesParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
Issues []db.Issue
-
DidHandleMap map[string]string
-
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
Issues []db.Issue
+
DidHandleMap map[string]string
+
Page pagination.Page
FilteringByOpen bool
}
···
}
type RepoSinglePullParams struct {
-
LoggedInUser *auth.User
-
RepoInfo RepoInfo
-
Active string
-
DidHandleMap map[string]string
-
Pull *db.Pull
-
PullSourceRepo *db.Repo
-
MergeCheck types.MergeCheckResponse
-
ResubmitCheck ResubmitResult
+
LoggedInUser *auth.User
+
RepoInfo RepoInfo
+
Active string
+
DidHandleMap map[string]string
+
Pull *db.Pull
+
MergeCheck types.MergeCheckResponse
+
ResubmitCheck ResubmitResult
}
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
DidHandleMap map[string]string
RepoInfo RepoInfo
Pull *db.Pull
-
Diff types.NiceDiff
+
Diff *types.NiceDiff
Round int
Submission *db.PullSubmission
}
···
// this name is a mouthful
func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
return p.execute("repo/pulls/patch", w, params)
+
}
+
+
type RepoPullInterdiffParams struct {
+
LoggedInUser *auth.User
+
DidHandleMap map[string]string
+
RepoInfo RepoInfo
+
Pull *db.Pull
+
Round int
+
Interdiff *patchutil.InterdiffResult
+
}
+
+
// this name is a mouthful
+
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
+
return p.execute("repo/pulls/interdiff", w, params)
}
type PullPatchUploadParams struct {
···
}
func (p *Pages) Static() http.Handler {
+
if p.dev {
+
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
+
}
+
sub, err := fs.Sub(Files, "static")
if err != nil {
log.Fatalf("no static dir found? that's crazy: %v", err)
+4 -17
appview/pages/templates/repo/fragments/diff.html
···
{{ $diff := index . 1 }}
{{ $commit := $diff.Commit }}
{{ $stat := $diff.Stat }}
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
{{ $diff := $diff.Diff }}
{{ $this := $commit.This }}
···
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
{{ block "statPill" $stat }} {{ end }}
</div>
-
<div class="overflow-x-auto">
-
{{ range $diff }}
-
<ul class="dark:text-gray-200">
-
{{ if .IsDelete }}
-
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
-
{{ else }}
-
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
-
{{ end }}
-
</ul>
-
{{ end }}
-
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
</div>
</section>
···
<summary class="list-none cursor-pointer sticky top-0">
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
-
<div class="flex gap-1 items-center" style="direction: ltr;">
+
<div class="flex gap-1 items-center">
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
{{ if .IsNew }}
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
···
{{ block "statPill" .Stats }} {{ end }}
</div>
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
+
<div class="flex gap-2 items-center overflow-x-auto">
{{ if .IsDelete }}
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
{{ .Name.Old }}
···
{{ else if .IsCopy }}
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
This file has been copied.
-
</p>
-
{{ else if .IsRename }}
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
-
This file has been renamed.
</p>
{{ else if .IsBinary }}
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+27
appview/pages/templates/repo/fragments/filetree.html
···
+
{{ define "fileTree" }}
+
{{ if and .Name .IsDirectory }}
+
<details open>
+
<summary class="cursor-pointer list-none pt-1">
+
<span class="inline-flex items-center gap-2 ">
+
{{ i "folder" "w-3 h-3 fill-current" }}
+
<span class="text-black dark:text-white">{{ .Name }}</span>
+
</span>
+
</summary>
+
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
</div>
+
</details>
+
{{ else if .Name }}
+
<div class="flex items-center gap-2 pt-1">
+
{{ i "file" "w-3 h-3" }}
+
<a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
+
</div>
+
{{ else }}
+
{{ range $child := .Children }}
+
{{ block "fileTree" $child }} {{ end }}
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+143
appview/pages/templates/repo/fragments/interdiff.html
···
+
{{ define "repo/fragments/interdiff" }}
+
{{ $repo := index . 0 }}
+
{{ $x := index . 1 }}
+
{{ $fileTree := fileTree $x.AffectedFiles }}
+
{{ $diff := $x.Files }}
+
+
<section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div class="diff-stat">
+
<div class="flex gap-2 items-center">
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
+
</div>
+
{{ block "fileTree" $fileTree }} {{ end }}
+
</div>
+
</section>
+
+
{{ $last := sub (len $diff) 1 }}
+
{{ range $idx, $hunk := $diff }}
+
{{ with $hunk }}
+
<section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm">
+
<div id="file-{{ .Name }}">
+
<div id="diff-file">
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
+
<summary class="list-none cursor-pointer sticky top-0">
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+
<div class="flex gap-1 items-center" style="direction: ltr;">
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
+
{{ if .Status.IsOk }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
+
{{ else if .Status.IsUnchanged }}
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
+
{{ else if .Status.IsOnlyInOne }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
+
{{ else if .Status.IsOnlyInTwo }}
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
+
{{ else if .Status.IsRebased }}
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
+
{{ else }}
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
+
{{ end }}
+
</div>
+
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
+
{{ .Name }}
+
</a>
+
</div>
+
</div>
+
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
+
<div id="right-side-items" class="p-2 flex items-center">
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
+
{{ if gt $idx 0 }}
+
{{ $prev := index $diff (sub $idx 1) }}
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
+
{{ end }}
+
+
{{ if lt $idx $last }}
+
{{ $next := index $diff (add $idx 1) }}
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
+
{{ end }}
+
</div>
+
+
</div>
+
</summary>
+
+
<div class="transition-all duration-700 ease-in-out">
+
{{ if .Status.IsUnchanged }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This file has not been changed.
+
</p>
+
{{ else if .Status.IsRebased }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
This patch was likely rebased, as context lines do not match.
+
</p>
+
{{ else if .Status.IsError }}
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+
Failed to calculate interdiff for this file.
+
</p>
+
{{ else }}
+
{{ $name := .Name }}
+
<pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div>
+
{{- $oldStart := .OldPosition -}}
+
{{- $newStart := .NewPosition -}}
+
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}}
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
+
{{- $lineNrSepStyle1 := "" -}}
+
{{- $lineNrSepStyle2 := "pr-2" -}}
+
{{- range .Lines -}}
+
{{- if eq .Op.String "+" -}}
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String "-" -}}
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- if eq .Op.String " " -}}
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
+
<div class="px-2">{{ .Line }}</div>
+
</div>
+
{{- $newStart = add64 $newStart 1 -}}
+
{{- $oldStart = add64 $oldStart 1 -}}
+
{{- end -}}
+
{{- end -}}
+
{{- end -}}</div></div></pre>
+
{{- end -}}
+
</div>
+
+
</details>
+
+
</div>
+
</div>
+
</section>
+
{{ end }}
+
{{ end }}
+
{{ end }}
+
+
{{ define "statPill" }}
+
<div class="flex items-center font-mono text-sm">
+
{{ if and .Insertions .Deletions }}
+
<span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
<span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ else if .Insertions }}
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
+
{{ else if .Deletions }}
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
+
{{ end }}
+
</div>
+
{{ end }}
+38
appview/pages/templates/repo/issues/issues.html
···
</div>
{{ end }}
</div>
+
+
{{ block "pagination" . }} {{ end }}
+
+
{{ end }}
+
+
{{ define "pagination" }}
+
<div class="flex justify-end mt-4 gap-2">
+
{{ $currentState := "closed" }}
+
{{ if .FilteringByOpen }}
+
{{ $currentState = "open" }}
+
{{ end }}
+
+
{{ if gt .Page.Offset 0 }}
+
{{ $prev := .Page.Previous }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
+
>
+
{{ i "chevron-left" "w-4 h-4" }}
+
previous
+
</a>
+
{{ else }}
+
<div></div>
+
{{ end }}
+
+
{{ if eq (len .Issues) .Page.Limit }}
+
{{ $next := .Page.Next }}
+
<a
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
+
hx-boost="true"
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
+
>
+
next
+
{{ i "chevron-right" "w-4 h-4" }}
+
</a>
+
{{ end }}
+
</div>
{{ end }}
+5
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
</select>
</div>
</div>
+
+
<p class="mt-4">
+
Title and description are optional; if left out, they will be extracted
+
from the first commit.
+
</p>
{{ end }}
+4
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
</div>
</div>
</div>
+
<p class="mt-4">
+
Title and description are optional; if left out, they will be extracted
+
from the first commit.
+
</p>
{{ end }}
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
+
{{ define "repo/pulls/fragments/pullHeader" }}
+
<header class="pb-4">
+
<h1 class="text-2xl dark:text-white">
+
{{ .Pull.Title }}
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
+
</h1>
+
</header>
+
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
+
{{ $icon := "ban" }}
+
+
{{ if .Pull.State.IsOpen }}
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
+
{{ $icon = "git-pull-request" }}
+
{{ else if .Pull.State.IsMerged }}
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
+
{{ $icon = "git-merge" }}
+
{{ end }}
+
+
<section class="mt-2">
+
<div class="flex items-center gap-2">
+
<div
+
id="state"
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
+
>
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
+
<span class="text-white">{{ .Pull.State.String }}</span>
+
</div>
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
+
opened by
+
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
+
<a href="/{{ $owner }}" class="no-underline hover:underline"
+
>{{ $owner }}</a
+
>
+
<span class="select-none before:content-['\00B7']"></span>
+
<time>{{ .Pull.Created | timeFmt }}</time>
+
<span class="select-none before:content-['\00B7']"></span>
+
<span>
+
targeting
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
+
</span>
+
</span>
+
{{ if not .Pull.IsPatchBased }}
+
<span>from
+
{{ if .Pull.IsForkBased }}
+
{{ if .Pull.PullSource.Repo }}
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>
+
{{ else }}
+
<span class="italic">[deleted fork]</span>
+
{{ end }}
+
{{ end }}
+
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
+
{{ .Pull.PullSource.Branch }}
+
</span>
+
</span>
+
{{ end }}
+
</span>
+
</div>
+
+
{{ if .Pull.Body }}
+
<article id="body" class="mt-8 prose dark:prose-invert">
+
{{ .Pull.Body | markdown }}
+
</article>
+
{{ end }}
+
</section>
+
+
+
{{ end }}
+11 -4
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
{{ define "repo/pulls/fragments/pullPatchUpload" }}
<div id="patch-upload">
+
<p>
+
You can paste a <code>git diff</code> or a
+
<code>git format-patch</code> patch series here.
+
</p>
<textarea
+
hx-trigger="keyup changed delay:500ms, paste delay:500ms"
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch"
+
hx-swap="none"
name="patch"
id="patch"
rows="12"
-
class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
placeholder="diff --git a/file.txt b/file.txt
-
index 1234567..abcdefg 100644
-
--- a/file.txt
-
+++ b/file.txt"
+
index 1234567..abcdefg 100644
+
--- a/file.txt
+
+++ b/file.txt"
></textarea>
</div>
{{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
···
+
{{ define "title" }}
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }}
+
{{ end }}
+
+
{{ define "content" }}
+
<section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
+
<header class="pb-2">
+
<div class="flex gap-3 items-center mb-3">
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+
{{ i "arrow-left" "w-5 h-5" }}
+
back
+
</a>
+
<span class="select-none before:content-['\00B7']"></span>
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}
+
</div>
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
+
{{ template "repo/pulls/fragments/pullHeader" . }}
+
</header>
+
</section>
+
+
<section>
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
+
</section>
+
{{ end }}
+
+90 -81
appview/pages/templates/repo/pulls/new.html
···
hx-swap="none"
>
<div class="flex flex-col gap-4">
-
<div>
-
<label for="title" class="dark:text-white">write a title</label>
-
<input type="text" name="title" id="title" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" />
-
</div>
+
<label>configure your pull request</label>
-
<div>
-
<label for="body" class="dark:text-white">add a description</label>
-
<textarea
-
name="body"
-
id="body"
-
rows="6"
-
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
placeholder="Describe your change. Markdown is supported."
-
></textarea>
-
</div>
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
+
<div class="pb-2">
+
<select
+
required
+
name="targetBranch"
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
>
+
<option disabled selected>target branch</option>
+
{{ range .Branches }}
+
<option value="{{ .Reference.Name }}" class="py-1">
+
{{ .Reference.Name }}
+
</option>
+
{{ end }}
+
</select>
+
</div>
+
<p>Next, choose a pull strategy.</p>
+
<nav class="flex space-x-4 items-end">
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
paste patch
+
</button>
-
<label>configure your pull request</label>
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
+
or
+
</span>
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
compare branches
+
</button>
+
{{ end }}
-
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
-
<div class="pb-2">
-
<select
-
required
-
name="targetBranch"
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
-
>
-
<option disabled selected>target branch</option>
-
{{ range .Branches }}
-
<option value="{{ .Reference.Name }}" class="py-1">
-
{{ .Reference.Name }}
-
</option>
-
{{ end }}
-
</select>
-
</div>
+
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
+
or
+
</span>
+
<button
+
type="button"
+
class="px-3 py-2 pb-2 btn"
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
+
hx-target="#patch-strategy"
+
hx-swap="innerHTML"
+
>
+
compare forks
+
</button>
+
</nav>
+
+
<section id="patch-strategy">
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
+
</section>
+
+
<p id="patch-preview"></p>
-
<p>Then, choose a pull strategy.</p>
-
<nav class="flex space-x-4 items-end">
-
<button
-
type="button"
-
class="px-3 py-2 pb-2 btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML"
-
>
-
paste patch
-
</button>
+
<div id="patch-error" class="error dark:text-red-300"></div>
-
{{ if .RepoInfo.Roles.IsPushAllowed }}
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
-
or
-
</span>
-
<button
-
type="button"
-
class="px-3 py-2 pb-2 btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML"
-
>
-
compare branches
-
</button>
-
{{ end }}
+
<div>
+
<label for="title" class="dark:text-white">write a title</label>
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
-
or
-
</span>
-
<button
-
type="button"
-
class="px-3 py-2 pb-2 btn"
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
-
hx-target="#patch-strategy"
-
hx-swap="innerHTML"
-
>
-
compare forks
-
</button>
-
</nav>
+
<input
+
type="text"
+
name="title"
+
id="title"
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="One-line summary of your change."
+
/>
+
</div>
-
<section id="patch-strategy">
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
-
</section>
+
<div>
+
<label for="body" class="dark:text-white"
+
>add a description</label
+
>
-
<div class="flex justify-start items-center gap-2 mt-4">
-
<button type="submit" class="btn flex items-center gap-2">
-
{{ i "git-pull-request-create" "w-4 h-4" }}
-
create pull
-
</button>
-
</div>
+
<textarea
+
name="body"
+
id="body"
+
rows="6"
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
+
placeholder="Describe your change. Markdown is supported."
+
></textarea>
+
</div>
+
<div class="flex justify-start items-center gap-2 mt-4">
+
<button type="submit" class="btn flex items-center gap-2">
+
{{ i "git-pull-request-create" "w-4 h-4" }}
+
create pull
+
</button>
+
</div>
</div>
<div id="pull" class="error dark:text-red-300"></div>
</form>
{{ end }}
-
-
{{ define "repoAfter" }}
-
<div id="patch-preview" class="error dark:text-red-300"></div>
-
{{ end }}
+21 -71
appview/pages/templates/repo/pulls/patch.html
···
{{ end }}
{{ define "content" }}
-
{{ $stat := .Diff.Stat }}
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
-
<header class="pb-2">
-
<div class="flex gap-3 items-center mb-3">
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
-
{{ i "arrow-left" "w-5 h-5" }}
-
back
-
</a>
-
<span class="select-none before:content-['\00B7']"></span>
-
round #{{ .Round }}
-
<span class="select-none before:content-['\00B7']"></span>
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
-
view raw
-
</a>
-
</div>
-
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
-
<h1 class="text-2xl mt-3">
-
{{ .Pull.Title }}
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
-
</h1>
-
</header>
-
-
{{ $bgColor := "bg-gray-800" }}
-
{{ $icon := "ban" }}
-
-
{{ if .Pull.State.IsOpen }}
-
{{ $bgColor = "bg-green-600" }}
-
{{ $icon = "git-pull-request" }}
-
{{ else if .Pull.State.IsMerged }}
-
{{ $bgColor = "bg-purple-600" }}
-
{{ $icon = "git-merge" }}
-
{{ end }}
-
-
<section>
-
<div class="flex items-center gap-2">
-
<div
-
id="state"
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
-
>
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
-
<span class="text-white">{{ .Pull.State.String }}</span>
-
</div>
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
-
opened by
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
<span class="select-none before:content-['\00B7']"></span>
-
<time>{{ .Pull.Created | timeFmt }}</time>
-
<span class="select-none before:content-['\00B7']"></span>
-
<span>targeting branch
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
-
{{ .Pull.TargetBranch }}
-
</span>
-
</span>
-
</span>
-
</div>
-
-
{{ if .Pull.Body }}
-
<article id="body" class="mt-2 prose dark:prose-invert">
-
{{ .Pull.Body | markdown }}
-
</article>
-
{{ end }}
-
</section>
-
-
</div>
-
-
<section>
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
-
</section>
+
<section>
+
<section
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
+
>
+
<div class="flex gap-3 items-center mb-3">
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
+
{{ i "arrow-left" "w-5 h-5" }}
+
back
+
</a>
+
<span class="select-none before:content-['\00B7']"></span>
+
round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span>
+
<span class="select-none before:content-['\00B7']"></span>
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
+
view raw
+
</a>
+
</div>
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
+
{{ template "repo/pulls/fragments/pullHeader" . }}
+
</section>
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
+
</section>
{{ end }}
+81 -82
appview/pages/templates/repo/pulls/pull.html
···
{{ end }}
{{ define "repoContent" }}
-
<header class="pb-4">
-
<h1 class="text-2xl dark:text-white">
-
{{ .Pull.Title }}
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
-
</h1>
-
</header>
-
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
-
{{ $icon := "ban" }}
-
-
{{ if .Pull.State.IsOpen }}
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
-
{{ $icon = "git-pull-request" }}
-
{{ else if .Pull.State.IsMerged }}
-
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
-
{{ $icon = "git-merge" }}
-
{{ end }}
-
-
<section class="mt-2">
-
<div class="flex items-center gap-2">
-
<div
-
id="state"
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
-
>
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
-
<span class="text-white">{{ .Pull.State.String }}</span>
-
</div>
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
-
opened by
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
-
>{{ $owner }}</a
-
>
-
<span class="select-none before:content-['\00B7']"></span>
-
<time>{{ .Pull.Created | timeFmt }}</time>
-
<span class="select-none before:content-['\00B7']"></span>
-
<span>
-
targeting
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
-
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
-
</span>
-
</span>
-
{{ if not .Pull.IsPatchBased }}
-
<span>from
-
{{ if not .Pull.IsBranchBased }}
-
<a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a>
-
{{ end }}
-
-
{{ $fullRepo := .RepoInfo.FullName }}
-
{{ if not .Pull.IsBranchBased }}
-
{{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }}
-
{{ end }}
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
-
<a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
-
</span>
-
</span>
-
{{ end }}
-
</span>
-
</div>
-
-
{{ if .Pull.Body }}
-
<article id="body" class="mt-8 prose dark:prose-invert">
-
{{ .Pull.Body | markdown }}
-
</article>
-
{{ end }}
-
</section>
-
+
{{ template "repo/pulls/fragments/pullHeader" . }}
{{ end }}
{{ define "repoAfter" }}
···
{{ $targetBranch := .Pull.TargetBranch }}
{{ $repoName := .RepoInfo.FullName }}
{{ range $idx, $item := .Pull.Submissions }}
-
{{ $diff := $item.AsNiceDiff $targetBranch }}
{{ with $item }}
<details {{ if eq $idx $lastIdx }}open{{ end }}>
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
<div class="flex flex-wrap gap-2 items-center">
<!-- round number -->
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
-
#{{ .RoundNumber }}
+
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
</div>
<!-- round summary -->
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
···
{{ len .Comments }} comment{{$s}}
</span>
</div>
-
<!-- view patch -->
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
hx-boost="true"
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
</a>
+
{{ if not (eq .RoundNumber 0) }}
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
+
hx-boost="true"
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
+
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
+
</a>
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
+
{{ end }}
</div>
</summary>
-
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
-
{{ range .Comments }}
-
<div id="comment-{{.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
+
+
{{ if .IsFormatPatch }}
+
{{ $patches := .AsFormatPatch }}
+
{{ $round := .RoundNumber }}
+
<details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm">
+
<summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
+
{{ $s := "s" }}
+
{{ if eq (len $patches) 1 }}
+
{{ $s = "" }}
+
{{ end }}
+
<div class="group-open:hidden flex items-center gap-2 ml-2">
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}}
+
</div>
+
<div class="hidden group-open:flex items-center gap-2 ml-2">
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}}
+
</div>
+
</summary>
+
{{ range $patches }}
+
<div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col">
+
<div class="flex items-center gap-2">
+
{{ i "git-commit-horizontal" "w-4 h-4" }}
+
<div class="text-sm text-gray-500 dark:text-gray-400">
+
{{ if not $.Pull.IsPatchBased }}
+
{{ $fullRepo := $.RepoInfo.FullName }}
+
{{ if $.Pull.IsForkBased }}
+
{{ if $.Pull.PullSource.Repo }}
+
{{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }}
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
+
{{ else }}
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
+
{{ end }}
+
{{ end }}
+
{{ else }}
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
+
{{ end }}
+
</div>
+
<div class="flex items-center">
+
<span>{{ .Title }}</span>
+
{{ if gt (len .Body) 0 }}
+
<button
+
class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
+
hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')"
+
>
+
{{ i "ellipsis" "w-3 h-3" }}
+
</button>
+
{{ end }}
+
</div>
+
</div>
+
{{ if gt (len .Body) 0 }}
+
<p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">
+
{{ nl2br .Body }}
+
</p>
+
{{ end }}
+
</div>
+
{{ end }}
+
</details>
+
{{ end }}
+
+
+
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
+
{{ range $cidx, $c := .Comments }}
+
<div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
+
{{ if gt $cidx 0 }}
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
+
{{ end }}
<div class="text-sm text-gray-500 dark:text-gray-400">
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
<a href="/{{$owner}}">{{$owner}}</a>
<span class="before:content-['ยท']"></span>
-
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a>
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a>
</div>
<div class="prose dark:prose-invert">
-
{{ .Body | markdown }}
+
{{ $c.Body | markdown }}
</div>
</div>
{{ end }}
···
{{ define "mergeStatus" }}
{{ if .Pull.State.IsClosed }}
<div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-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 dark:bg-gray-600"></div>
<div class="flex items-center gap-2 text-black dark:text-white">
{{ i "ban" "w-4 h-4" }}
<span class="font-medium">closed without merging</span
···
</div>
{{ else if .Pull.State.IsMerged }}
<div class="bg-purple-50 dark:bg-purple-900 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 dark:bg-gray-600"></div>
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
{{ i "git-merge" "w-4 h-4" }}
<span class="font-medium">pull request successfully merged</span
···
</div>
{{ else if and .MergeCheck .MergeCheck.Error }}
<div class="bg-red-50 dark:bg-red-900 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 dark:bg-gray-600"></div>
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
{{ i "triangle-alert" "w-4 h-4" }}
<span class="font-medium">{{ .MergeCheck.Error }}</span>
···
</div>
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
<div class="bg-red-50 dark:bg-red-900 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 dark:bg-gray-600"></div>
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
<div class="flex items-center gap-2">
{{ i "triangle-alert" "w-4 h-4" }}
···
</div>
{{ else if .MergeCheck }}
<div class="bg-green-50 dark:bg-green-900 border border-green-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 dark:bg-gray-600"></div>
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
{{ i "circle-check-big" "w-4 h-4" }}
<span class="font-medium">no conflicts, ready to merge</span>
···
{{ define "resubmitStatus" }}
{{ if .ResubmitCheck.Yes }}
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-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 dark:bg-gray-600"></div>
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
{{ i "triangle-alert" "w-4 h-4" }}
<span class="font-medium">this branch has been updated, consider resubmitting</span>
···
</div>
{{ end }}
{{ end }}
+
+
{{ define "commits" }}
+
{{ end }}
+1 -1
appview/pages/templates/user/profile.html
···
{{ if gt $stats.Closed 0 }}
-
<span class="px-2 py-1/2 text-sm rounded text-black dark:text-white bg-gray-50 dark:bg-gray-700 ">
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
{{$stats.Closed}} closed
</span>
{{ end }}
+31
appview/pagination/page.go
···
+
package pagination
+
+
type Page struct {
+
Offset int // where to start from
+
Limit int // number of items in a page
+
}
+
+
func FirstPage() Page {
+
return Page{
+
Offset: 0,
+
Limit: 10,
+
}
+
}
+
+
func (p Page) Previous() Page {
+
if p.Offset-p.Limit < 0 {
+
return FirstPage()
+
} else {
+
return Page{
+
Offset: p.Offset - p.Limit,
+
Limit: p.Limit,
+
}
+
}
+
}
+
+
func (p Page) Next() Page {
+
return Page{
+
Offset: p.Offset + p.Limit,
+
Limit: p.Limit,
+
}
+
}
+451
appview/settings/settings.go
···
+
package settings
+
+
import (
+
"database/sql"
+
"errors"
+
"fmt"
+
"log"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
+
"tangled.sh/tangled.sh/core/appview/auth"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/email"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/gliderlabs/ssh"
+
"github.com/google/uuid"
+
)
+
+
type Settings struct {
+
Db *db.DB
+
Auth *auth.Auth
+
Pages *pages.Pages
+
Config *appview.Config
+
}
+
+
func (s *Settings) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Use(middleware.AuthMiddleware(s.Auth))
+
+
r.Get("/", s.settings)
+
+
r.Route("/keys", func(r chi.Router) {
+
r.Put("/", s.keys)
+
r.Delete("/", s.keys)
+
})
+
+
r.Route("/emails", func(r chi.Router) {
+
r.Put("/", s.emails)
+
r.Delete("/", s.emails)
+
r.Get("/verify", s.emailsVerify)
+
r.Post("/verify/resend", s.emailsVerifyResend)
+
r.Post("/primary", s.emailsPrimary)
+
})
+
+
return r
+
}
+
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
+
user := s.Auth.GetUser(r)
+
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
emails, err := db.GetAllEmails(s.Db, user.Did)
+
if err != nil {
+
log.Println(err)
+
}
+
+
s.Pages.Settings(w, pages.SettingsParams{
+
LoggedInUser: user,
+
PubKeys: pubKeys,
+
Emails: emails,
+
})
+
}
+
+
// buildVerificationEmail creates an email.Email struct for verification emails
+
func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
+
verifyURL := s.verifyUrl(did, emailAddr, code)
+
+
return email.Email{
+
APIKey: s.Config.ResendApiKey,
+
From: "noreply@notifs.tangled.sh",
+
To: emailAddr,
+
Subject: "Verify your Tangled email",
+
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
+
` + verifyURL,
+
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
+
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
+
}
+
}
+
+
// sendVerificationEmail handles the common logic for sending verification emails
+
func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
+
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
+
+
err := email.SendEmail(emailToSend)
+
if err != nil {
+
log.Printf("sending email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
+
return err
+
}
+
+
return nil
+
}
+
+
func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-emails", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// check if email already exists in database
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err == nil {
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
+
return
+
}
+
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddEmail(tx, db.Email{
+
Did: did,
+
Address: emAddr,
+
Verified: false,
+
VerificationCode: code,
+
}); err != nil {
+
log.Printf("adding email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
+
return
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
+
log.Printf("deleting email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+
+
func (s *Settings) verifyUrl(did string, email string, code string) string {
+
var appUrl string
+
if s.Config.Dev {
+
appUrl = "http://" + s.Config.ListenAddr
+
} else {
+
appUrl = "https://tangled.sh"
+
}
+
+
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
+
}
+
+
func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
+
q := r.URL.Query()
+
+
// Get the parameters directly from the query
+
emailAddr := q.Get("email")
+
did := q.Get("did")
+
code := q.Get("code")
+
+
valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
+
if err != nil {
+
log.Printf("checking email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
+
return
+
}
+
+
if !valid {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
+
return
+
}
+
+
// Mark email as verified in the database
+
if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
+
log.Printf("marking email as verified: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
+
return
+
}
+
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
+
}
+
+
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
+
if r.Method != http.MethodPost {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
+
return
+
}
+
+
did := s.Auth.GetDid(r)
+
emAddr := r.FormValue("email")
+
emAddr = strings.TrimSpace(emAddr)
+
+
if !email.IsValidEmail(emAddr) {
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
+
return
+
}
+
+
// Check if email exists and is unverified
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
+
if err != nil {
+
if errors.Is(err, sql.ErrNoRows) {
+
s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
+
} else {
+
log.Printf("checking for existing email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
}
+
return
+
}
+
+
if existingEmail.Verified {
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
+
return
+
}
+
+
// Check if last verification email was sent less than 10 minutes ago
+
if existingEmail.LastSent != nil {
+
timeSinceLastSent := time.Since(*existingEmail.LastSent)
+
if timeSinceLastSent < 10*time.Minute {
+
waitTime := 10*time.Minute - timeSinceLastSent
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
+
return
+
}
+
}
+
+
// Generate new verification code
+
code := uuid.New().String()
+
+
// Begin transaction
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
// Update the verification code and last sent time
+
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
+
log.Printf("updating email verification: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
// Send verification email
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
+
return
+
}
+
+
// Commit transaction
+
if err := tx.Commit(); err != nil {
+
log.Printf("failed to commit transaction: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
+
return
+
}
+
+
s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
+
}
+
+
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
+
did := s.Auth.GetDid(r)
+
emailAddr := r.FormValue("email")
+
emailAddr = strings.TrimSpace(emailAddr)
+
+
if emailAddr == "" {
+
s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
+
return
+
}
+
+
if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
+
log.Printf("setting primary email: %s", err)
+
s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
}
+
+
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.Pages.Notice(w, "settings-keys", "Unimplemented.")
+
log.Println("unimplemented")
+
return
+
case http.MethodPut:
+
did := s.Auth.GetDid(r)
+
key := r.FormValue("key")
+
key = strings.TrimSpace(key)
+
name := r.FormValue("name")
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
+
if err != nil {
+
log.Printf("parsing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
+
return
+
}
+
+
rkey := appview.TID()
+
+
tx, err := s.Db.Begin()
+
if err != nil {
+
log.Printf("failed to start tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
defer tx.Rollback()
+
+
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
+
log.Printf("adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
+
return
+
}
+
+
// store in pds too
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
Record: &lexutil.LexiconTypeDecoder{
+
Val: &tangled.PublicKey{
+
Created: time.Now().Format(time.RFC3339),
+
Key: key,
+
Name: name,
+
}},
+
})
+
// invalid record
+
if err != nil {
+
log.Printf("failed to create record: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to create record.")
+
return
+
}
+
+
log.Println("created atproto record: ", resp.Uri)
+
+
err = tx.Commit()
+
if err != nil {
+
log.Printf("failed to commit tx; adding public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
+
return
+
}
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
+
case http.MethodDelete:
+
did := s.Auth.GetDid(r)
+
q := r.URL.Query()
+
+
name := q.Get("name")
+
rkey := q.Get("rkey")
+
key := q.Get("key")
+
+
log.Println(name)
+
log.Println(rkey)
+
log.Println(key)
+
+
client, _ := s.Auth.AuthorizedClient(r)
+
+
if err := db.RemovePublicKey(s.Db, did, name, key); err != nil {
+
log.Printf("removing public key: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
+
return
+
}
+
+
if rkey != "" {
+
// remove from pds too
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
+
Collection: tangled.PublicKeyNSID,
+
Repo: did,
+
Rkey: rkey,
+
})
+
+
// invalid record
+
if err != nil {
+
log.Printf("failed to delete record from PDS: %s", err)
+
s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
+
return
+
}
+
}
+
log.Println("deleted successfully")
+
+
s.Pages.HxLocation(w, "/settings")
+
return
+
}
+
}
+2 -1
appview/state/follow.go
···
comatproto "github.com/bluesky-social/indigo/api/atproto"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.GraphFollowNSID,
Repo: currentUser.Did,
+7 -92
appview/state/middleware.go
···
"slices"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
-
"github.com/bluesky-social/indigo/xrpc"
"github.com/go-chi/chi/v5"
-
"tangled.sh/tangled.sh/core/appview"
-
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/middleware"
)
-
type Middleware func(http.Handler) http.Handler
-
-
func AuthMiddleware(s *State) Middleware {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
-
}
-
if r.Header.Get("HX-Request") == "true" {
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
-
w.Header().Set("HX-Redirect", "/login")
-
w.WriteHeader(http.StatusOK)
-
}
-
}
-
-
session, err := s.auth.GetSession(r)
-
if session.IsNew || err != nil {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
-
if !ok || !authorized {
-
log.Printf("not logged in, redirecting")
-
redirectFunc(w, r)
-
return
-
}
-
-
// refresh if nearing expiry
-
// TODO: dedup with /login
-
expiryStr := session.Values[appview.SessionExpiry].(string)
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
-
if err != nil {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
-
did, ok2 := session.Values[appview.SessionDid].(string)
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
-
-
if !ok1 || !ok2 || !ok3 {
-
log.Println("invalid expiry time", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
if time.Now().After(expiry) {
-
log.Println("token expired, refreshing ...")
-
-
client := xrpc.Client{
-
Host: pdsUrl,
-
Auth: &xrpc.AuthInfo{
-
Did: did,
-
AccessJwt: refreshJwt,
-
RefreshJwt: refreshJwt,
-
},
-
}
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
-
if err != nil {
-
log.Println("failed to refresh session", err)
-
redirectFunc(w, r)
-
return
-
}
-
-
sessionish := auth.RefreshSessionWrapper{atSession}
-
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
-
if err != nil {
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
-
return
-
}
-
-
log.Println("successfully refreshed token")
-
}
-
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
-
-
func knotRoleMiddleware(s *State, group string) Middleware {
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
}
}
-
func KnotOwner(s *State) Middleware {
+
func KnotOwner(s *State) middleware.Middleware {
return knotRoleMiddleware(s, "server:owner")
}
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// requires auth also
···
})
}
-
func ResolveIdent(s *State) Middleware {
+
func ResolveIdent(s *State) middleware.Middleware {
excluded := []string{"favicon.ico"}
return func(next http.Handler) http.Handler {
···
}
}
-
func ResolveRepo(s *State) Middleware {
+
func ResolveRepo(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
repoName := chi.URLParam(req, "repo")
···
}
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
-
func ResolvePull(s *State) Middleware {
+
func ResolvePull(s *State) middleware.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f, err := fullyResolvedRepo(r)
+183 -80
appview/state/pull.go
···
"net/http"
"net/url"
"strconv"
-
"strings"
"time"
-
"github.com/go-chi/chi/v5"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
)
// htmx fragment
···
resubmitResult = s.resubmitCheck(f, pull)
}
-
var pullSourceRepo *db.Repo
-
if pull.PullSource != nil {
-
if pull.PullSource.RepoAt != nil {
-
pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
-
if err != nil {
-
log.Printf("failed to get repo by at uri: %v", err)
-
return
-
}
-
}
-
}
-
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
-
LoggedInUser: user,
-
RepoInfo: f.RepoInfo(s, user),
-
DidHandleMap: didHandleMap,
-
Pull: pull,
-
PullSourceRepo: pullSourceRepo,
-
MergeCheck: mergeCheckResponse,
-
ResubmitCheck: resubmitResult,
+
LoggedInUser: user,
+
RepoInfo: f.RepoInfo(s, user),
+
DidHandleMap: didHandleMap,
+
Pull: pull,
+
MergeCheck: mergeCheckResponse,
+
ResubmitCheck: resubmitResult,
})
}
···
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
if latestSubmission.SourceRev != result.Branch.Hash {
+
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
return pages.ShouldResubmit
}
···
}
}
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
+
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
LoggedInUser: user,
DidHandleMap: didHandleMap,
···
Pull: pull,
Round: roundIdInt,
Submission: pull.Submissions[roundIdInt],
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
+
Diff: &diff,
})
}
+
func (s *State) RepoPullInterdiff(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 get pull.")
+
return
+
}
+
+
roundId := chi.URLParam(r, "round")
+
roundIdInt, err := strconv.Atoi(roundId)
+
if err != nil || roundIdInt >= len(pull.Submissions) {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("failed to parse round id", err)
+
return
+
}
+
+
if roundIdInt == 0 {
+
http.Error(w, "bad round id", http.StatusBadRequest)
+
log.Println("cannot interdiff initial submission")
+
return
+
}
+
+
identsToResolve := []string{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()
+
}
+
}
+
+
currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
+
if err != nil {
+
log.Println("failed to interdiff; current patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
+
return
+
}
+
+
previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
+
if err != nil {
+
log.Println("failed to interdiff; previous patch malformed")
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
+
return
+
}
+
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
+
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
+
LoggedInUser: s.auth.GetUser(r),
+
RepoInfo: f.RepoInfo(s, user),
+
Pull: pull,
+
Round: roundIdInt,
+
DidHandleMap: didHandleMap,
+
Interdiff: interdiff,
+
})
+
return
+
}
+
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
pull, ok := r.Context().Value("pull").(*db.Pull)
if !ok {
···
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoPullCommentNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoPullComment{
Repo: &atUri,
···
sourceBranch := r.FormValue("sourceBranch")
patch := r.FormValue("patch")
-
// Validate required fields for all PR types
-
if title == "" || body == "" || targetBranch == "" {
-
s.pages.Notice(w, "pull", "Title, body and target branch are required.")
-
return
-
}
-
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
-
if err != nil {
-
log.Println("failed to create unsigned client to %s: %v", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
-
return
-
}
-
-
caps, err := us.Capabilities()
-
if err != nil {
-
log.Println("error fetching knot caps", f.Knot, err)
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
if targetBranch == "" {
+
s.pages.Notice(w, "pull", "Target branch is required.")
return
}
···
isForkBased := fromFork != "" && sourceBranch != ""
isPatchBased := patch != "" && !isBranchBased && !isForkBased
+
if isPatchBased && !patchutil.IsFormatPatch(patch) {
+
if title == "" {
+
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
+
return
+
}
+
}
+
// Validate we have at least one valid PR creation method
if !isBranchBased && !isPatchBased && !isForkBased {
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
···
return
}
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
+
if err != nil {
+
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
return
+
}
+
+
caps, err := us.Capabilities()
+
if err != nil {
+
log.Println("error fetching knot caps", f.Knot, err)
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
+
return
+
}
+
+
if !caps.PullRequests.FormatPatch {
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
+
return
+
}
+
// Handle the PR creation based on the type
if isBranchBased {
if !caps.PullRequests.BranchSubmissions {
···
return
}
-
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
if err != nil {
log.Println("failed to compare", err)
s.pages.Notice(w, "pull", err.Error())
return
}
-
sourceRev := diffTreeResponse.DiffTree.Rev2
-
patch := diffTreeResponse.DiffTree.Patch
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
-
if !isPatchValid(patch) {
+
if !patchutil.IsPatchValid(patch) {
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
return
}
···
}
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
-
if !isPatchValid(patch) {
+
if !patchutil.IsPatchValid(patch) {
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
return
}
···
// hiddenRef: hidden/feature-1/main (on repo-fork)
// targetBranch: main (on repo-1)
// sourceBranch: feature-1 (on repo-fork)
-
diffTreeResponse, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
if err != nil {
log.Println("failed to compare across branches", err)
s.pages.Notice(w, "pull", err.Error())
return
}
-
sourceRev := diffTreeResponse.DiffTree.Rev2
-
patch := diffTreeResponse.DiffTree.Patch
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
-
if !isPatchValid(patch) {
+
if !patchutil.IsPatchValid(patch) {
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
return
}
···
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
}
-
func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) {
+
func (s *State) createPullRequest(
+
w http.ResponseWriter,
+
r *http.Request,
+
f *FullyResolvedRepo,
+
user *auth.User,
+
title, body, targetBranch string,
+
patch string,
+
sourceRev string,
+
pullSource *db.PullSource,
+
recordPullSource *tangled.RepoPull_Source,
+
) {
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
log.Println("failed to start tx")
···
}
defer tx.Rollback()
-
rkey := s.TID()
+
// We've already checked earlier if it's diff-based and title is empty,
+
// so if it's still empty now, it's intentionally skipped owing to format-patch.
+
if title == "" {
+
formatPatches, err := patchutil.ExtractPatches(patch)
+
if err != nil {
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
+
return
+
}
+
if len(formatPatches) == 0 {
+
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
+
return
+
}
+
+
title = formatPatches[0].Title
+
body = formatPatches[0].Body
+
}
+
+
rkey := appview.TID()
initialSubmission := db.PullSubmission{
Patch: patch,
SourceRev: sourceRev,
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
}
+
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
+
_, err := fullyResolvedRepo(r)
+
if err != nil {
+
log.Println("failed to get repo and knot", err)
+
return
+
}
+
+
patch := r.FormValue("patch")
+
if patch == "" {
+
s.pages.Notice(w, "patch-error", "Patch is required.")
+
return
+
}
+
+
if patch == "" || !patchutil.IsPatchValid(patch) {
+
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
+
return
+
}
+
+
if patchutil.IsFormatPatch(patch) {
+
s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
+
} else {
+
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
+
}
+
}
+
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
···
if err = validateResubmittedPatch(pull, patch); err != nil {
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
tx, err := s.db.BeginTx(r.Context(), nil)
···
return
-
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
if err != nil {
log.Printf("compare request failed: %s", err)
s.pages.Notice(w, "resubmit-error", err.Error())
return
-
sourceRev := diffTreeResponse.DiffTree.Rev2
-
patch := diffTreeResponse.DiffTree.Patch
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
if err = validateResubmittedPatch(pull, patch); err != nil {
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
···
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
-
diffTreeResponse, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
if err != nil {
log.Printf("failed to compare branches: %s", err)
s.pages.Notice(w, "resubmit-error", err.Error())
return
-
sourceRev := diffTreeResponse.DiffTree.Rev2
-
patch := diffTreeResponse.DiffTree.Patch
+
sourceRev := comparison.Rev2
+
patch := comparison.Patch
if err = validateResubmittedPatch(pull, patch); err != nil {
s.pages.Notice(w, "resubmit-error", err.Error())
+
return
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
···
return fmt.Errorf("Patch is identical to previous submission.")
-
if !isPatchValid(patch) {
+
if !patchutil.IsPatchValid(patch) {
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
···
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
return
-
-
// Very basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines
-
func isPatchValid(patch string) bool {
-
// Basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines
-
if len(patch) == 0 {
-
return false
-
}
-
-
lines := strings.Split(patch, "\n")
-
if len(lines) < 2 {
-
return false
-
}
-
-
// Check for common patch format markers
-
firstLine := strings.TrimSpace(lines[0])
-
return strings.HasPrefix(firstLine, "diff ") ||
-
strings.HasPrefix(firstLine, "--- ") ||
-
strings.HasPrefix(firstLine, "Index: ") ||
-
strings.HasPrefix(firstLine, "+++ ") ||
-
strings.HasPrefix(firstLine, "@@ ")
-
}
+14 -5
appview/state/repo.go
···
"github.com/go-chi/chi/v5"
"github.com/go-git/go-git/v5/plumbing"
"tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/auth"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
"tangled.sh/tangled.sh/core/appview/pages/markup"
+
"tangled.sh/tangled.sh/core/appview/pagination"
"tangled.sh/tangled.sh/core/types"
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueStateNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssueState{
Issue: issue.IssueAt,
···
commentId := mathrand.IntN(1000000)
-
rkey := s.TID()
+
rkey := appview.TID()
err := db.NewIssueComment(s.db, &db.Comment{
OwnerDid: user.Did,
···
isOpen = true
+
page, ok := r.Context().Value("page").(pagination.Page)
+
if !ok {
+
log.Println("failed to get page")
+
page = pagination.FirstPage()
+
}
+
user := s.auth.GetUser(r)
f, err := fullyResolvedRepo(r)
if err != nil {
···
return
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
if err != nil {
log.Println("failed to get issues", err)
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
Issues: issues,
DidHandleMap: didHandleMap,
FilteringByOpen: isOpen,
+
Page: page,
})
return
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoIssueNSID,
Repo: user.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.RepoIssue{
Repo: atUri,
···
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
sourceAt := f.RepoAt.String()
-
rkey := s.TID()
+
rkey := appview.TID()
repo := &db.Repo{
Did: user.Did,
Name: forkName,
+28 -23
appview/state/router.go
···
"strings"
"github.com/go-chi/chi/v5"
+
"tangled.sh/tangled.sh/core/appview/middleware"
+
"tangled.sh/tangled.sh/core/appview/settings"
"tangled.sh/tangled.sh/core/appview/state/userutil"
)
···
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
r.Route("/issues", func(r chi.Router) {
-
r.Get("/", s.RepoIssues)
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
r.Get("/{issue}", s.RepoSingleIssue)
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/new", s.NewIssue)
r.Post("/new", s.NewIssue)
r.Post("/{issue}/comment", s.NewIssueComment)
···
})
r.Route("/fork", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.ForkRepo)
r.Post("/", s.ForkRepo)
})
r.Route("/pulls", func(r chi.Router) {
r.Get("/", s.RepoPulls)
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
r.Get("/", s.NewPull)
r.Get("/patch-upload", s.PatchUploadFragment)
+
r.Post("/validate-patch", s.ValidatePatch)
r.Get("/compare-branches", s.CompareBranchesFragment)
r.Get("/compare-forks", s.CompareForksFragment)
r.Get("/fork-branches", s.CompareForksBranchesFragment)
···
r.Route("/round/{round}", func(r chi.Router) {
r.Get("/", s.RepoPullPatch)
+
r.Get("/interdiff", s.RepoPullInterdiff)
r.Get("/actions", s.PullActions)
-
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
r.Get("/", s.PullComment)
r.Post("/", s.PullComment)
})
···
})
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Route("/resubmit", func(r chi.Router) {
r.Get("/", s.ResubmitPull)
r.Post("/", s.ResubmitPull)
···
// settings routes, needs auth
r.Group(func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
// repo description can only be edited by owner
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
r.Put("/", s.RepoDescription)
···
r.Get("/", s.Timeline)
-
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
r.Route("/login", func(r chi.Router) {
r.Get("/", s.Login)
···
})
r.Route("/knots", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.Knots)
r.Post("/key", s.RegistrationKey)
···
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
+
r.Use(middleware.AuthMiddleware(s.auth))
r.Get("/", s.NewRepo)
r.Post("/", s.NewRepo)
})
// r.Post("/import", s.ImportRepo)
})
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
r.Post("/", s.Follow)
r.Delete("/", s.Follow)
})
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
r.Post("/", s.Star)
r.Delete("/", s.Star)
})
-
r.Route("/settings", func(r chi.Router) {
-
r.Use(AuthMiddleware(s))
-
r.Get("/", s.Settings)
-
r.Put("/keys", s.SettingsKeys)
-
r.Delete("/keys", s.SettingsKeys)
-
r.Put("/emails", s.SettingsEmails)
-
r.Delete("/emails", s.SettingsEmails)
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
-
})
+
r.Mount("/settings", s.SettingsRouter())
r.Get("/keys/{user}", s.Keys)
···
})
return r
}
+
+
func (s *State) SettingsRouter() http.Handler {
+
settings := &settings.Settings{
+
Db: s.db,
+
Auth: s.auth,
+
Pages: s.pages,
+
Config: s.config,
+
}
+
+
return settings.Router()
+
}
-416
appview/state/settings.go
···
-
package state
-
-
import (
-
"database/sql"
-
"errors"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"strings"
-
"time"
-
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
"github.com/gliderlabs/ssh"
-
"github.com/google/uuid"
-
"tangled.sh/tangled.sh/core/api/tangled"
-
"tangled.sh/tangled.sh/core/appview/db"
-
"tangled.sh/tangled.sh/core/appview/email"
-
"tangled.sh/tangled.sh/core/appview/pages"
-
)
-
-
func (s *State) Settings(w http.ResponseWriter, r *http.Request) {
-
user := s.auth.GetUser(r)
-
pubKeys, err := db.GetPublicKeys(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
emails, err := db.GetAllEmails(s.db, user.Did)
-
if err != nil {
-
log.Println(err)
-
}
-
-
s.pages.Settings(w, pages.SettingsParams{
-
LoggedInUser: user,
-
PubKeys: pubKeys,
-
Emails: emails,
-
})
-
}
-
-
// buildVerificationEmail creates an email.Email struct for verification emails
-
func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {
-
verifyURL := s.verifyUrl(did, emailAddr, code)
-
-
return email.Email{
-
APIKey: s.config.ResendApiKey,
-
From: "noreply@notifs.tangled.sh",
-
To: emailAddr,
-
Subject: "Verify your Tangled email",
-
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
-
` + verifyURL,
-
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
-
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
-
}
-
}
-
-
// sendVerificationEmail handles the common logic for sending verification emails
-
func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
-
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
-
-
err := email.SendEmail(emailToSend)
-
if err != nil {
-
log.Printf("sending email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
-
return err
-
}
-
-
return nil
-
}
-
-
func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-emails", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// check if email already exists in database
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err == nil {
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
-
return
-
}
-
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddEmail(tx, db.Email{
-
Did: did,
-
Address: emAddr,
-
Verified: false,
-
VerificationCode: code,
-
}); err != nil {
-
log.Printf("adding email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
-
return
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
-
log.Printf("deleting email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
-
-
func (s *State) verifyUrl(did string, email string, code string) string {
-
var appUrl string
-
if s.config.Dev {
-
appUrl = "http://" + s.config.ListenAddr
-
} else {
-
appUrl = "https://tangled.sh"
-
}
-
-
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
-
}
-
-
func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) {
-
q := r.URL.Query()
-
-
// Get the parameters directly from the query
-
emailAddr := q.Get("email")
-
did := q.Get("did")
-
code := q.Get("code")
-
-
valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code)
-
if err != nil {
-
log.Printf("checking email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
-
return
-
}
-
-
if !valid {
-
s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
-
return
-
}
-
-
// Mark email as verified in the database
-
if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil {
-
log.Printf("marking email as verified: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
-
return
-
}
-
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
-
}
-
-
func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {
-
if r.Method != http.MethodPost {
-
s.pages.Notice(w, "settings-emails-error", "Invalid request method.")
-
return
-
}
-
-
did := s.auth.GetDid(r)
-
emAddr := r.FormValue("email")
-
emAddr = strings.TrimSpace(emAddr)
-
-
if !email.IsValidEmail(emAddr) {
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
-
return
-
}
-
-
// Check if email exists and is unverified
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
-
if err != nil {
-
if errors.Is(err, sql.ErrNoRows) {
-
s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
-
} else {
-
log.Printf("checking for existing email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
}
-
return
-
}
-
-
if existingEmail.Verified {
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
-
return
-
}
-
-
// Check if last verification email was sent less than 10 minutes ago
-
if existingEmail.LastSent != nil {
-
timeSinceLastSent := time.Since(*existingEmail.LastSent)
-
if timeSinceLastSent < 10*time.Minute {
-
waitTime := 10*time.Minute - timeSinceLastSent
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
-
return
-
}
-
}
-
-
// Generate new verification code
-
code := uuid.New().String()
-
-
// Begin transaction
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
// Update the verification code and last sent time
-
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
-
log.Printf("updating email verification: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
// Send verification email
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
-
return
-
}
-
-
// Commit transaction
-
if err := tx.Commit(); err != nil {
-
log.Printf("failed to commit transaction: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
-
return
-
}
-
-
s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
-
}
-
-
func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
-
did := s.auth.GetDid(r)
-
emailAddr := r.FormValue("email")
-
emailAddr = strings.TrimSpace(emailAddr)
-
-
if emailAddr == "" {
-
s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
-
return
-
}
-
-
if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil {
-
log.Printf("setting primary email: %s", err)
-
s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
}
-
-
func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
s.pages.Notice(w, "settings-keys", "Unimplemented.")
-
log.Println("unimplemented")
-
return
-
case http.MethodPut:
-
did := s.auth.GetDid(r)
-
key := r.FormValue("key")
-
key = strings.TrimSpace(key)
-
name := r.FormValue("name")
-
client, _ := s.auth.AuthorizedClient(r)
-
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
-
if err != nil {
-
log.Printf("parsing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
-
return
-
}
-
-
rkey := s.TID()
-
-
tx, err := s.db.Begin()
-
if err != nil {
-
log.Printf("failed to start tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
defer tx.Rollback()
-
-
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
-
log.Printf("adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to add public key.")
-
return
-
}
-
-
// store in pds too
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
Record: &lexutil.LexiconTypeDecoder{
-
Val: &tangled.PublicKey{
-
Created: time.Now().Format(time.RFC3339),
-
Key: key,
-
Name: name,
-
}},
-
})
-
// invalid record
-
if err != nil {
-
log.Printf("failed to create record: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to create record.")
-
return
-
}
-
-
log.Println("created atproto record: ", resp.Uri)
-
-
err = tx.Commit()
-
if err != nil {
-
log.Printf("failed to commit tx; adding public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
-
return
-
}
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
-
case http.MethodDelete:
-
did := s.auth.GetDid(r)
-
q := r.URL.Query()
-
-
name := q.Get("name")
-
rkey := q.Get("rkey")
-
key := q.Get("key")
-
-
log.Println(name)
-
log.Println(rkey)
-
log.Println(key)
-
-
client, _ := s.auth.AuthorizedClient(r)
-
-
if err := db.RemovePublicKey(s.db, did, name, key); err != nil {
-
log.Printf("removing public key: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove public key.")
-
return
-
}
-
-
if rkey != "" {
-
// remove from pds too
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
-
Collection: tangled.PublicKeyNSID,
-
Repo: did,
-
Rkey: rkey,
-
})
-
-
// invalid record
-
if err != nil {
-
log.Printf("failed to delete record from PDS: %s", err)
-
s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
-
return
-
}
-
}
-
log.Println("deleted successfully")
-
-
s.pages.HxLocation(w, "/settings")
-
return
-
}
-
}
+6 -6
appview/state/signer.go
···
return &capabilities, nil
}
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoDiffTreeResponse, error) {
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
const (
Method = "GET"
)
···
}
defer compareResp.Body.Close()
-
var diffTreeResponse types.RepoDiffTreeResponse
-
err = json.Unmarshal(respBody, &diffTreeResponse)
+
var formatPatchResponse types.RepoFormatPatchResponse
+
err = json.Unmarshal(respBody, &formatPatchResponse)
if err != nil {
-
log.Println("failed to unmarshal diff tree response", err)
-
return nil, fmt.Errorf("Failed to compare branches.")
+
log.Println("failed to unmarshal format-patch response", err)
+
return nil, fmt.Errorf("failed to compare branches.")
}
-
return &diffTreeResponse, nil
+
return &formatPatchResponse, nil
}
+2 -1
appview/state/star.go
···
"github.com/bluesky-social/indigo/atproto/syntax"
lexutil "github.com/bluesky-social/indigo/lex/util"
tangled "tangled.sh/tangled.sh/core/api/tangled"
+
"tangled.sh/tangled.sh/core/appview"
"tangled.sh/tangled.sh/core/appview/db"
"tangled.sh/tangled.sh/core/appview/pages"
)
···
switch r.Method {
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
-
rkey := s.TID()
+
rkey := appview.TID()
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.FeedStarNSID,
Repo: currentUser.Did,
+5 -5
appview/state/state.go
···
clock := syntax.NewTIDClock(0)
-
pgs := pages.NewPages()
+
pgs := pages.NewPages(config.Dev)
resolver := appview.NewResolver()
···
return state, nil
}
-
func (s *State) TID() string {
-
return s.tidClock.Next().String()
+
func TID(c *syntax.TIDClock) string {
+
return c.Next().String()
}
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
···
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
Repo: currentUser.Did,
-
Rkey: s.TID(),
+
Rkey: appview.TID(),
Record: &lexutil.LexiconTypeDecoder{
Val: &tangled.KnotMember{
Member: memberIdent.DID.String(),
···
return
}
-
rkey := s.TID()
+
rkey := appview.TID()
repo := &db.Repo{
Did: user.Did,
Name: repoName,
+11
appview/tid.go
···
+
package appview
+
+
import (
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
var c *syntax.TIDClock = syntax.NewTIDClock(0)
+
+
func TID() string {
+
return c.Next().String()
+
}
+38
cmd/combinediff/main.go
···
+
package main
+
+
import (
+
"fmt"
+
"os"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
+
)
+
+
func main() {
+
if len(os.Args) != 3 {
+
fmt.Println("Usage: combinediff <patch1> <patch2>")
+
os.Exit(1)
+
}
+
+
patch1, err := os.Open(os.Args[1])
+
if err != nil {
+
fmt.Println(err)
+
}
+
patch2, err := os.Open(os.Args[2])
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files1, _, err := gitdiff.Parse(patch1)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files2, _, err := gitdiff.Parse(patch2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
combined := patchutil.CombineDiff(files1, files2)
+
fmt.Println(combined)
+
}
+38
cmd/interdiff/main.go
···
+
package main
+
+
import (
+
"fmt"
+
"os"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
"tangled.sh/tangled.sh/core/patchutil"
+
)
+
+
func main() {
+
if len(os.Args) != 3 {
+
fmt.Println("Usage: interdiff <patch1> <patch2>")
+
os.Exit(1)
+
}
+
+
patch1, err := os.Open(os.Args[1])
+
if err != nil {
+
fmt.Println(err)
+
}
+
patch2, err := os.Open(os.Args[2])
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files1, _, err := gitdiff.Parse(patch1)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
files2, _, err := gitdiff.Parse(patch2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
interDiffResult := patchutil.Interdiff(files1, files2)
+
fmt.Println(interDiffResult)
+
}
+9 -7
docs/contributing.md
···
### message format
```
-
<service/top-level directory>: <package/path>: <short summary of change>
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
-
Optional longer description, if needed. Explain what the change does and
-
why, especially if not obvious. Reference relevant issues or PRs when
-
applicable. These can be links for now since we don't auto-link
-
issues/PRs yet.
+
Optional longer description can go here, if necessary. Explain what the
+
change does and why, especially if not obvious. Reference relevant
+
issues or PRs when applicable. These can be links for now since we don't
+
auto-link issues/PRs yet.
```
Here are some examples:
···
### general notes
-
- PRs get merged as a single commit, so keep PRs small and focused. Use
-
the above guidelines for the PR title and description.
+
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
+
using `git am`. At present, there is no squashing -- so please author
+
your commits as they would appear on `master`, following the above
+
guidelines.
- Use the imperative mood in the summary line (e.g., "fix bug" not
"fixed bug" or "fixes bug").
- Try to keep the summary line under 72 characters, but we aren't too
+82
docs/knot-hosting.md
···
You should now have a running knot server! You can finalize your registration by hitting the
`initialize` button on the [/knots](/knots) page.
+
+
### custom paths
+
+
(This section applies to manual setup only. Docker users should edit the mounts
+
in `docker-compose.yml` instead.)
+
+
Right now, the database and repositories of your knot lives in `/home/git`. You
+
can move these paths if you'd like to store them in another folder. Be careful
+
when adjusting these paths:
+
+
* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
+
any possible side effects. Remember to restart it once you're done.
+
* Make backups before moving in case something goes wrong.
+
* Make sure the `git` user can read and write from the new paths.
+
+
#### database
+
+
As an example, let's say the current database is at `/home/git/knotserver.db`,
+
and we want to move it to `/home/git/database/knotserver.db`.
+
+
Copy the current database to the new location. Make sure to copy the `.db-shm`
+
and `.db-wal` files if they exist.
+
+
```
+
mkdir /home/git/database
+
cp /home/git/knotserver.db* /home/git/database
+
```
+
+
In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to
+
the new file path (_not_ the directory):
+
+
```
+
KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db
+
```
+
+
#### repositories
+
+
As an example, let's say the repositories are currently in `/home/git`, and we
+
want to move them into `/home/git/repositories`.
+
+
Create the new folder, then move the existing repositories (if there are any):
+
+
```
+
mkdir /home/git/repositories
+
# move all DIDs into the new folder; these will vary for you!
+
mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories
+
```
+
+
In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH`
+
to the new directory:
+
+
```
+
KNOT_REPO_SCAN_PATH=/home/git/repositories
+
```
+
+
In your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`),
+
update the `AuthorizedKeysCommand` line to use the new folder. For example:
+
+
```
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
+
AuthorizedKeysCommandUser nobody
+
```
+
+
Make sure to restart your SSH server!
+
+
#### git
+
+
The keyfetch executable takes multiple arguments to change certain paths. You
+
can view a full list by running `/usr/local/libexec/tangled-keyfetch -h`.
+
+
As an example, if you wanted to change the path to the repoguard executable,
+
you would edit your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`)
+
and update the `AuthorizedKeysCommand` line:
+
+
```
+
Match User git
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -repoguard-path /path/to/repoguard
+
AuthorizedKeysCommandUser nobody
+
```
+
+
Make sure to restart your SSH server!
+3 -3
flake.lock
···
"indigo": {
"flake": false,
"locked": {
-
"lastModified": 1738491661,
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
+
"lastModified": 1745333930,
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
"owner": "oppiliappan",
"repo": "indigo",
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
"type": "github"
},
"original": {
+11 -2
flake.nix
···
inherit (gitignore.lib) gitignoreSource;
in {
overlays.default = final: prev: let
-
goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4=";
+
goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=";
buildCmdPackage = name:
final.buildGoModule {
pname = name;
···
${pkgs.air}/bin/air -c /dev/null \
-build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \
-build.bin "./out/${name}.out" \
-
-build.include_ext "go,html,css"
+
-build.include_ext "go"
+
'';
+
tailwind-watcher =
+
pkgs.writeShellScriptBin "run"
+
''
+
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
'';
in {
watch-appview = {
···
watch-knotserver = {
type = "app";
program = ''${air-watcher "knotserver"}/bin/run'';
+
};
+
watch-tailwind = {
+
type = "app";
+
program = ''${tailwind-watcher}/bin/run'';
};
});
+3 -3
go.mod
···
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
-
golang.org/x/crypto v0.36.0 // indirect
-
golang.org/x/net v0.37.0 // indirect
-
golang.org/x/sys v0.31.0 // indirect
+
golang.org/x/crypto v0.37.0 // indirect
+
golang.org/x/net v0.39.0 // indirect
+
golang.org/x/sys v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
+10 -10
go.sum
···
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
-
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+14 -9
input.css
···
font-size: 15px;
}
@supports (font-variation-settings: normal) {
-
html {
-
font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'tnum' 1;
-
}
+
html {
+
font-feature-settings:
+
"ss01" 1,
+
"kern" 1,
+
"liga" 1,
+
"cv05" 1,
+
"tnum" 1;
+
}
}
a {
···
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
}
input {
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
+
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
}
textarea {
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
+
@apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
}
details summary::-webkit-details-marker {
display: none;
···
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
-
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: #ccd0da }
+
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) }
/* GenericEmph */ .chroma .ge { font-style: italic }
/* GenericError */ .chroma .gr { color: #d20f39 }
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
-
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: #ccd0da }
+
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) }
/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
···
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
-
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: #363a4f }
+
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) }
/* GenericEmph */ .chroma .ge { font-style: italic }
/* GenericError */ .chroma .gr { color: #ed8796 }
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
-
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: #363a4f }
+
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) }
/* GenericStrong */ .chroma .gs { font-weight: bold }
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
+31
knotserver/git/diff.go
···
package git
import (
+
"bytes"
"fmt"
"log"
+
"os"
+
"os/exec"
"strings"
"github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.sh/tangled.sh/core/patchutil"
"tangled.sh/tangled.sh/core/types"
)
···
Patch: patch.String(),
Diff: diffs,
}, nil
+
}
+
+
// FormatPatch generates a git-format-patch output between two commits,
+
// and returns the raw format-patch series, a parsed FormatPatch and an error.
+
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
+
var stdout bytes.Buffer
+
cmd := exec.Command(
+
"git",
+
"-C",
+
g.path,
+
"format-patch",
+
fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()),
+
"--stdout",
+
)
+
cmd.Stdout = &stdout
+
cmd.Stderr = os.Stderr
+
err := cmd.Run()
+
if err != nil {
+
return "", nil, err
+
}
+
+
formatPatch, err := patchutil.ExtractPatches(stdout.String())
+
if err != nil {
+
return "", nil, err
+
}
+
+
return stdout.String(), formatPatch, nil
}
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
+8 -2
knotserver/routes.go
···
capabilities := map[string]any{
"pull_requests": map[string]any{
+
"format_patch": true,
"patch_submissions": true,
"branch_submissions": true,
"fork_submissions": true,
···
return
}
-
difftree, err := gr.DiffTree(mergeBase, commit2)
+
rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2)
if err != nil {
l.Error("error comparing revisions", "msg", err.Error())
writeError(w, "error comparing revisions", http.StatusBadRequest)
return
}
-
writeJSON(w, types.RepoDiffTreeResponse{difftree})
+
writeJSON(w, types.RepoFormatPatchResponse{
+
Rev1: commit1.Hash.String(),
+
Rev2: commit2.Hash.String(),
+
FormatPatch: formatPatch,
+
Patch: rawPatch,
+
})
return
}
+168
patchutil/combinediff.go
···
+
package patchutil
+
+
import (
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
// original1 -> patch1 -> rev1
+
// original2 -> patch2 -> rev2
+
//
+
// original2 must be equal to rev1, so we can merge them to get maximal context
+
//
+
// finally,
+
// rev2' <- apply(patch2, merged)
+
// combineddiff <- diff(rev2', original1)
+
func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {
+
fileName := bestName(file1)
+
+
o1 := CreatePreImage(file1)
+
r1 := CreatePostImage(file1)
+
o2 := CreatePreImage(file2)
+
+
merged, err := r1.Merge(&o2)
+
if err != nil {
+
return nil, err
+
}
+
+
r2Prime, err := merged.Apply(file2)
+
if err != nil {
+
return nil, err
+
}
+
+
// produce combined diff
+
diff, err := Unified(o1.String(), fileName, r2Prime, fileName)
+
if err != nil {
+
return nil, err
+
}
+
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
+
+
if len(parsed) != 1 {
+
// no diff? the second commit reverted the changes from the first
+
return nil, nil
+
}
+
+
return parsed[0], nil
+
}
+
+
// use empty lines for lines we are unaware of
+
//
+
// this raises an error only if the two patches were invalid or non-contiguous
+
func mergeLines(old, new string) (string, error) {
+
var i, j int
+
+
// TODO: use strings.Lines
+
linesOld := strings.Split(old, "\n")
+
linesNew := strings.Split(new, "\n")
+
+
result := []string{}
+
+
for i < len(linesOld) || j < len(linesNew) {
+
if i >= len(linesOld) {
+
// rest of the file is populated from `new`
+
result = append(result, linesNew[j])
+
j++
+
continue
+
}
+
+
if j >= len(linesNew) {
+
// rest of the file is populated from `old`
+
result = append(result, linesOld[i])
+
i++
+
continue
+
}
+
+
oldLine := linesOld[i]
+
newLine := linesNew[j]
+
+
if oldLine != newLine && (oldLine != "" && newLine != "") {
+
// context mismatch
+
return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine)
+
}
+
+
if oldLine == newLine {
+
result = append(result, oldLine)
+
} else if oldLine == "" {
+
result = append(result, newLine)
+
} else if newLine == "" {
+
result = append(result, oldLine)
+
}
+
i++
+
j++
+
}
+
+
return strings.Join(result, "\n"), nil
+
}
+
+
func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {
+
fileToIdx1 := make(map[string]int)
+
fileToIdx2 := make(map[string]int)
+
visited := make(map[string]struct{})
+
var result []*gitdiff.File
+
+
for idx, f := range patch1 {
+
fileToIdx1[bestName(f)] = idx
+
}
+
+
for idx, f := range patch2 {
+
fileToIdx2[bestName(f)] = idx
+
}
+
+
for _, f1 := range patch1 {
+
fileName := bestName(f1)
+
if idx, ok := fileToIdx2[fileName]; ok {
+
f2 := patch2[idx]
+
+
// we have f1 and f2, combine them
+
combined, err := combineFiles(f1, f2)
+
if err != nil {
+
fmt.Println(err)
+
}
+
+
result = append(result, combined)
+
} else {
+
// only in patch1; add as-is
+
result = append(result, f1)
+
}
+
+
visited[fileName] = struct{}{}
+
}
+
+
// for all files in patch2 that remain unvisited; we can just add them into the output
+
for _, f2 := range patch2 {
+
fileName := bestName(f2)
+
if _, ok := visited[fileName]; ok {
+
continue
+
}
+
+
result = append(result, f2)
+
}
+
+
return result
+
}
+
+
// pairwise combination from first to last patch
+
func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {
+
if len(patches) == 0 {
+
return nil
+
}
+
+
if len(patches) == 1 {
+
return patches[0]
+
}
+
+
combined := combineTwo(patches[0], patches[1])
+
+
newPatches := [][]*gitdiff.File{}
+
newPatches = append(newPatches, combined)
+
for i, p := range patches {
+
if i >= 2 {
+
newPatches = append(newPatches, p)
+
}
+
}
+
+
return CombineDiff(newPatches...)
+
}
+178
patchutil/image.go
···
+
package patchutil
+
+
import (
+
"bytes"
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type Line struct {
+
LineNumber int64
+
Content string
+
IsUnknown bool
+
}
+
+
func NewLineAt(lineNumber int64, content string) Line {
+
return Line{
+
LineNumber: lineNumber,
+
Content: content,
+
IsUnknown: false,
+
}
+
}
+
+
type Image struct {
+
File string
+
Data []*Line
+
}
+
+
func (r *Image) String() string {
+
var i, j int64
+
var b strings.Builder
+
for {
+
i += 1
+
+
if int(j) >= (len(r.Data)) {
+
break
+
}
+
+
if r.Data[j].LineNumber == i {
+
// b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
+
b.WriteString(r.Data[j].Content)
+
j += 1
+
} else {
+
//b.WriteString(fmt.Sprintf("%d:\n", i))
+
b.WriteString("\n")
+
}
+
}
+
+
return b.String()
+
}
+
+
func (r *Image) AddLine(line *Line) {
+
r.Data = append(r.Data, line)
+
}
+
+
// rebuild the original file from a patch
+
func CreatePreImage(file *gitdiff.File) Image {
+
rf := Image{
+
File: bestName(file),
+
}
+
+
for _, fragment := range file.TextFragments {
+
position := fragment.OldPosition
+
for _, line := range fragment.Lines {
+
switch line.Op {
+
case gitdiff.OpContext:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpDelete:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpAdd:
+
// do nothing here
+
}
+
}
+
}
+
+
return rf
+
}
+
+
// rebuild the revised file from a patch
+
func CreatePostImage(file *gitdiff.File) Image {
+
rf := Image{
+
File: bestName(file),
+
}
+
+
for _, fragment := range file.TextFragments {
+
position := fragment.NewPosition
+
for _, line := range fragment.Lines {
+
switch line.Op {
+
case gitdiff.OpContext:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpAdd:
+
rl := NewLineAt(position, line.Line)
+
rf.Data = append(rf.Data, &rl)
+
position += 1
+
case gitdiff.OpDelete:
+
// do nothing here
+
}
+
}
+
}
+
+
return rf
+
}
+
+
type MergeError struct {
+
msg string
+
mismatchingLine int64
+
}
+
+
func (m MergeError) Error() string {
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
+
}
+
+
// best effort merging of two reconstructed files
+
func (this *Image) Merge(other *Image) (*Image, error) {
+
mergedFile := Image{}
+
+
var i, j int64
+
+
for int(i) < len(this.Data) || int(j) < len(other.Data) {
+
if int(i) >= len(this.Data) {
+
// first file is done; the rest of the lines from file 2 can go in
+
mergedFile.AddLine(other.Data[j])
+
j++
+
continue
+
}
+
+
if int(j) >= len(other.Data) {
+
// first file is done; the rest of the lines from file 2 can go in
+
mergedFile.AddLine(this.Data[i])
+
i++
+
continue
+
}
+
+
line1 := this.Data[i]
+
line2 := other.Data[j]
+
+
if line1.LineNumber == line2.LineNumber {
+
if line1.Content != line2.Content {
+
return nil, MergeError{
+
msg: "mismatching lines, this patch might have undergone rebase",
+
mismatchingLine: line1.LineNumber,
+
}
+
} else {
+
mergedFile.AddLine(line1)
+
}
+
i++
+
j++
+
} else if line1.LineNumber < line2.LineNumber {
+
mergedFile.AddLine(line1)
+
i++
+
} else {
+
mergedFile.AddLine(line2)
+
j++
+
}
+
}
+
+
return &mergedFile, nil
+
}
+
+
func (r *Image) Apply(patch *gitdiff.File) (string, error) {
+
original := r.String()
+
var buffer bytes.Buffer
+
reader := strings.NewReader(original)
+
+
err := gitdiff.Apply(&buffer, reader, patch)
+
if err != nil {
+
return "", err
+
}
+
+
return buffer.String(), nil
+
}
+244
patchutil/interdiff.go
···
+
package patchutil
+
+
import (
+
"fmt"
+
"strings"
+
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
+
)
+
+
type InterdiffResult struct {
+
Files []*InterdiffFile
+
}
+
+
func (i *InterdiffResult) AffectedFiles() []string {
+
files := make([]string, len(i.Files))
+
for _, f := range i.Files {
+
files = append(files, f.Name)
+
}
+
return files
+
}
+
+
func (i *InterdiffResult) String() string {
+
var b strings.Builder
+
for _, f := range i.Files {
+
b.WriteString(f.String())
+
b.WriteString("\n")
+
}
+
+
return b.String()
+
}
+
+
type InterdiffFile struct {
+
*gitdiff.File
+
Name string
+
Status InterdiffFileStatus
+
}
+
+
func (s *InterdiffFile) String() string {
+
var b strings.Builder
+
b.WriteString(s.Status.String())
+
b.WriteString(" ")
+
+
if s.File != nil {
+
b.WriteString(bestName(s.File))
+
b.WriteString("\n")
+
b.WriteString(s.File.String())
+
}
+
+
return b.String()
+
}
+
+
type InterdiffFileStatus struct {
+
StatusKind StatusKind
+
Error error
+
}
+
+
func (s *InterdiffFileStatus) String() string {
+
kind := s.StatusKind.String()
+
if s.Error != nil {
+
return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
+
} else {
+
return kind
+
}
+
}
+
+
func (s *InterdiffFileStatus) IsOk() bool {
+
return s.StatusKind == StatusOk
+
}
+
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
+
return s.StatusKind == StatusUnchanged
+
}
+
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
+
return s.StatusKind == StatusOnlyInOne
+
}
+
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
+
return s.StatusKind == StatusOnlyInTwo
+
}
+
+
func (s *InterdiffFileStatus) IsRebased() bool {
+
return s.StatusKind == StatusRebased
+
}
+
+
func (s *InterdiffFileStatus) IsError() bool {
+
return s.StatusKind == StatusError
+
}
+
+
type StatusKind int
+
+
func (k StatusKind) String() string {
+
switch k {
+
case StatusOnlyInOne:
+
return "only in one"
+
case StatusOnlyInTwo:
+
return "only in two"
+
case StatusUnchanged:
+
return "unchanged"
+
case StatusRebased:
+
return "rebased"
+
case StatusError:
+
return "error"
+
default:
+
return "changed"
+
}
+
}
+
+
const (
+
StatusOk StatusKind = iota
+
StatusOnlyInOne
+
StatusOnlyInTwo
+
StatusUnchanged
+
StatusRebased
+
StatusError
+
)
+
+
func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
+
re1 := CreatePreImage(f1)
+
re2 := CreatePreImage(f2)
+
+
interdiffFile := InterdiffFile{
+
Name: bestName(f1),
+
}
+
+
merged, err := re1.Merge(&re2)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusRebased,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
rev1, err := merged.Apply(f1)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
rev2, err := merged.Apply(f2)
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
+
if err != nil {
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusError,
+
Error: err,
+
}
+
return &interdiffFile
+
}
+
+
if len(parsed) != 1 {
+
// files are identical?
+
interdiffFile.Status = InterdiffFileStatus{
+
StatusKind: StatusUnchanged,
+
}
+
return &interdiffFile
+
}
+
+
if interdiffFile.Status.StatusKind == StatusOk {
+
interdiffFile.File = parsed[0]
+
}
+
+
return &interdiffFile
+
}
+
+
func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
+
fileToIdx1 := make(map[string]int)
+
fileToIdx2 := make(map[string]int)
+
visited := make(map[string]struct{})
+
var result InterdiffResult
+
+
for idx, f := range patch1 {
+
fileToIdx1[bestName(f)] = idx
+
}
+
+
for idx, f := range patch2 {
+
fileToIdx2[bestName(f)] = idx
+
}
+
+
for _, f1 := range patch1 {
+
var interdiffFile *InterdiffFile
+
+
fileName := bestName(f1)
+
if idx, ok := fileToIdx2[fileName]; ok {
+
f2 := patch2[idx]
+
+
// we have f1 and f2, calculate interdiff
+
interdiffFile = interdiffFiles(f1, f2)
+
} else {
+
// only in patch 1, this change would have to be "inverted" to dissapear
+
// from patch 2, so we reverseDiff(f1)
+
reverseDiff(f1)
+
+
interdiffFile = &InterdiffFile{
+
File: f1,
+
Name: fileName,
+
Status: InterdiffFileStatus{
+
StatusKind: StatusOnlyInOne,
+
},
+
}
+
}
+
+
result.Files = append(result.Files, interdiffFile)
+
visited[fileName] = struct{}{}
+
}
+
+
// for all files in patch2 that remain unvisited; we can just add them into the output
+
for _, f2 := range patch2 {
+
fileName := bestName(f2)
+
if _, ok := visited[fileName]; ok {
+
continue
+
}
+
+
result.Files = append(result.Files, &InterdiffFile{
+
File: f2,
+
Name: fileName,
+
Status: InterdiffFileStatus{
+
StatusKind: StatusOnlyInTwo,
+
},
+
})
+
}
+
+
return &result
+
}
+95 -20
patchutil/patchutil.go
···
import (
"fmt"
+
"os"
+
"os/exec"
"regexp"
"strings"
···
)
type FormatPatch struct {
+
Files []*gitdiff.File
*gitdiff.PatchHeader
-
Patch string
}
func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
···
result := []FormatPatch{}
for _, patch := range patches {
-
_, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
+
files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
if err != nil {
return nil, fmt.Errorf("failed to parse patch: %w", err)
}
···
}
result = append(result, FormatPatch{
+
Files: files,
PatchHeader: header,
-
Patch: patch,
})
}
return result, nil
}
-
// Very basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines or git format-patch header
+
// IsPatchValid checks if the given patch string is valid.
+
// It performs very basic sniffing for either git-diff or git-format-patch
+
// header lines. For format patches, it attempts to extract and validate each one.
func IsPatchValid(patch string) bool {
-
// Basic validation to check if it looks like a diff/patch
-
// A valid patch usually starts with diff or --- lines
if len(patch) == 0 {
return false
}
···
return false
}
-
// Check for common patch format markers
firstLine := strings.TrimSpace(lines[0])
-
return strings.HasPrefix(firstLine, "diff ") ||
+
+
// check if it's a git diff
+
if strings.HasPrefix(firstLine, "diff ") ||
strings.HasPrefix(firstLine, "--- ") ||
strings.HasPrefix(firstLine, "Index: ") ||
strings.HasPrefix(firstLine, "+++ ") ||
-
strings.HasPrefix(firstLine, "@@ ") ||
-
strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
-
strings.HasPrefix(firstLine, "From: ")
+
strings.HasPrefix(firstLine, "@@ ") {
+
return true
+
}
+
+
// check if it's format-patch
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
+
strings.HasPrefix(firstLine, "From: ") {
+
// ExtractPatches already runs it through gitdiff.Parse so if that errors,
+
// it's safe to say it's broken.
+
patches, err := ExtractPatches(patch)
+
if err != nil {
+
return false
+
}
+
return len(patches) > 0
+
}
+
+
return false
}
func IsFormatPatch(patch string) bool {
···
strings.HasPrefix(line, "commit ") {
headerCount++
}
-
if strings.HasPrefix(line, "diff --git ") {
-
return true
-
}
}
return headerCount >= 2
}
func splitFormatPatch(patchText string) []string {
-
// The pattern to match is "From " followed by a commit hash and the rest of that line
re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
-
// Find all starting positions of patches
indexes := re.FindAllStringIndex(patchText, -1)
if len(indexes) == 0 {
-
// No patches found
return []string{}
}
···
startPos := indexes[i][0]
endPos := len(patchText)
-
// If there's a next patch, set end position to the start of the next patch
if i < len(indexes)-1 {
endPos = indexes[i+1][0]
}
-
// Extract the patch and trim any whitespace
patches[i] = strings.TrimSpace(patchText[startPos:endPos])
}
return patches
}
+
+
func bestName(file *gitdiff.File) string {
+
if file.IsDelete {
+
return file.OldName
+
} else {
+
return file.NewName
+
}
+
}
+
+
// in-place reverse of a diff
+
func reverseDiff(file *gitdiff.File) {
+
file.OldName, file.NewName = file.NewName, file.OldName
+
file.OldMode, file.NewMode = file.NewMode, file.OldMode
+
file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
+
+
for _, fragment := range file.TextFragments {
+
// swap postions
+
fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
+
fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
+
fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
+
+
for i := range fragment.Lines {
+
switch fragment.Lines[i].Op {
+
case gitdiff.OpAdd:
+
fragment.Lines[i].Op = gitdiff.OpDelete
+
case gitdiff.OpDelete:
+
fragment.Lines[i].Op = gitdiff.OpAdd
+
default:
+
// do nothing
+
}
+
}
+
}
+
}
+
+
func Unified(oldText, oldFile, newText, newFile string) (string, error) {
+
oldTemp, err := os.CreateTemp("", "old_*")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
+
}
+
defer os.Remove(oldTemp.Name())
+
if _, err := oldTemp.WriteString(oldText); err != nil {
+
return "", fmt.Errorf("failed to write to old temp file: %w", err)
+
}
+
oldTemp.Close()
+
+
newTemp, err := os.CreateTemp("", "new_*")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temp file for newText: %w", err)
+
}
+
defer os.Remove(newTemp.Name())
+
if _, err := newTemp.WriteString(newText); err != nil {
+
return "", fmt.Errorf("failed to write to new temp file: %w", err)
+
}
+
newTemp.Close()
+
+
cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
+
output, err := cmd.CombinedOutput()
+
+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
+
return string(output), nil
+
}
+
if err != nil {
+
return "", fmt.Errorf("diff command failed: %w", err)
+
}
+
+
return string(output), nil
+
}
+115 -1
patchutil/patchutil_test.go
···
"testing"
)
+
func TestIsPatchValid(t *testing.T) {
+
tests := []struct {
+
name string
+
patch string
+
expected bool
+
}{
+
{
+
name: `empty patch`,
+
patch: ``,
+
expected: false,
+
},
+
{
+
name: `single line patch`,
+
patch: `single line`,
+
expected: false,
+
},
+
{
+
name: `valid diff patch`,
+
patch: `diff --git a/file.txt b/file.txt
+
index abc..def 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with ---`,
+
patch: `--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with Index`,
+
patch: `Index: file.txt
+
==========
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with +++`,
+
patch: `+++ b/file.txt
+
--- a/file.txt
+
@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context`,
+
expected: true,
+
},
+
{
+
name: `valid patch starting with @@`,
+
patch: `@@ -1,3 +1,3 @@
+
-old line
+
+new line
+
context
+
`,
+
expected: true,
+
},
+
{
+
name: `valid format patch`,
+
patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
+
Subject: [PATCH] Example patch
+
+
diff --git a/file.txt b/file.txt
+
index 123456..789012 100644
+
--- a/file.txt
+
+++ b/file.txt
+
@@ -1 +1 @@
+
-old content
+
+new content
+
--
+
2.48.1`,
+
expected: true,
+
},
+
{
+
name: `invalid format patch`,
+
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
+
From: Author <author@example.com>
+
This is not a valid patch format`,
+
expected: false,
+
},
+
{
+
name: `not a patch at all`,
+
patch: `This is
+
just some
+
random text
+
that isn't a patch`,
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := IsPatchValid(tt.patch)
+
if result != tt.expected {
+
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
+
}
+
})
+
}
+
}
+
func TestSplitPatches(t *testing.T) {
tests := []struct {
name string
···
},
{
name: "No valid patches",
-
input: "This is not a patch\nJust some random text",
+
input: "This is not a \nJust some random text",
expected: []string{},
},
{
+1
types/capabilities.go
···
type Capabilities struct {
PullRequests struct {
+
FormatPatch bool `json:"format_patch"`
PatchSubmissions bool `json:"patch_submissions"`
BranchSubmissions bool `json:"branch_submissions"`
ForkSubmissions bool `json:"fork_submissions"`
+14
types/diff.go
···
Patch string `json:"patch"`
Diff []*gitdiff.File `json:"diff"`
}
+
+
func (d *NiceDiff) ChangedFiles() []string {
+
files := make([]string, len(d.Diff))
+
+
for i, f := range d.Diff {
+
if f.IsDelete {
+
files[i] = f.Name.Old
+
} else {
+
files[i] = f.Name.New
+
}
+
}
+
+
return files
+
}
+6 -2
types/repo.go
···
import (
"github.com/go-git/go-git/v5/plumbing/object"
+
"tangled.sh/tangled.sh/core/patchutil"
)
type RepoIndexResponse struct {
···
Diff *NiceDiff `json:"diff,omitempty"`
}
-
type RepoDiffTreeResponse struct {
-
DiffTree *DiffTree `json:"difftree,omitempty"`
+
type RepoFormatPatchResponse struct {
+
Rev1 string `json:"rev1,omitempty"`
+
Rev2 string `json:"rev2,omitempty"`
+
FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"`
+
Patch string `json:"patch,omitempty"`
}
type RepoTreeResponse struct {