+35
-20
appview/db/issues.go
+35
-20
appview/db/issues.go
···
5
5
"time"
6
6
7
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
+
"tangled.sh/tangled.sh/core/appview/pagination"
8
9
)
9
10
10
11
type Issue struct {
···
102
103
return ownerDid, err
103
104
}
104
105
105
-
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) {
106
+
func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) {
106
107
var issues []Issue
107
108
openValue := 0
108
109
if isOpen {
···
110
111
}
111
112
112
113
rows, err := e.Query(
113
-
`select
114
-
i.owner_did,
115
-
i.issue_id,
116
-
i.created,
117
-
i.title,
118
-
i.body,
119
-
i.open,
120
-
count(c.id)
121
-
from
122
-
issues i
123
-
left join
124
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
125
-
where
126
-
i.repo_at = ? and i.open = ?
127
-
group by
128
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
129
-
order by
130
-
i.created desc`,
131
-
repoAt, openValue)
114
+
`
115
+
with numbered_issue as (
116
+
select
117
+
i.owner_did,
118
+
i.issue_id,
119
+
i.created,
120
+
i.title,
121
+
i.body,
122
+
i.open,
123
+
count(c.id) as comment_count,
124
+
row_number() over (order by i.created desc) as row_num
125
+
from
126
+
issues i
127
+
left join
128
+
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
129
+
where
130
+
i.repo_at = ? and i.open = ?
131
+
group by
132
+
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
133
+
)
134
+
select
135
+
owner_did,
136
+
issue_id,
137
+
created,
138
+
title,
139
+
body,
140
+
open,
141
+
comment_count
142
+
from
143
+
numbered_issue
144
+
where
145
+
row_num between ? and ?`,
146
+
repoAt, openValue, page.Offset+1, page.Offset+page.Limit)
132
147
if err != nil {
133
148
return nil, err
134
149
}
+54
-2
appview/db/pulls.go
+54
-2
appview/db/pulls.go
···
10
10
11
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
12
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.sh/tangled.sh/core/patchutil"
13
14
"tangled.sh/tangled.sh/core/types"
14
15
)
15
16
···
149
150
return false
150
151
}
151
152
152
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
153
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
153
154
patch := s.Patch
154
155
155
-
diffs, _, err := gitdiff.Parse(strings.NewReader(patch))
156
+
// if format-patch; then extract each patch
157
+
var diffs []*gitdiff.File
158
+
if patchutil.IsFormatPatch(patch) {
159
+
patches, err := patchutil.ExtractPatches(patch)
160
+
if err != nil {
161
+
return nil, err
162
+
}
163
+
var ps [][]*gitdiff.File
164
+
for _, p := range patches {
165
+
ps = append(ps, p.Files)
166
+
}
167
+
168
+
diffs = patchutil.CombineDiff(ps...)
169
+
} else {
170
+
d, _, err := gitdiff.Parse(strings.NewReader(patch))
171
+
if err != nil {
172
+
return nil, err
173
+
}
174
+
diffs = d
175
+
}
176
+
177
+
return diffs, nil
178
+
}
179
+
180
+
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
181
+
diffs, err := s.AsDiff(targetBranch)
156
182
if err != nil {
157
183
log.Println(err)
158
184
}
···
188
214
nd.Stat.FilesChanged = len(diffs)
189
215
190
216
return nd
217
+
}
218
+
219
+
func (s PullSubmission) IsFormatPatch() bool {
220
+
return patchutil.IsFormatPatch(s.Patch)
221
+
}
222
+
223
+
func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch {
224
+
patches, err := patchutil.ExtractPatches(s.Patch)
225
+
if err != nil {
226
+
log.Println("error extracting patches from submission:", err)
227
+
return []patchutil.FormatPatch{}
228
+
}
229
+
230
+
return patches
191
231
}
192
232
193
233
func NewPull(tx *sql.Tx, pull *Pull) error {
···
612
652
}
613
653
if err = commentsRows.Err(); err != nil {
614
654
return nil, err
655
+
}
656
+
657
+
var pullSourceRepo *Repo
658
+
if pull.PullSource != nil {
659
+
if pull.PullSource.RepoAt != nil {
660
+
pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String())
661
+
if err != nil {
662
+
log.Printf("failed to get repo by at uri: %v", err)
663
+
} else {
664
+
pull.PullSource.Repo = pullSourceRepo
665
+
}
666
+
}
615
667
}
616
668
617
669
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
+62
appview/filetree/filetree.go
+62
appview/filetree/filetree.go
···
1
+
package filetree
2
+
3
+
import (
4
+
"path/filepath"
5
+
"sort"
6
+
"strings"
7
+
)
8
+
9
+
type FileTreeNode struct {
10
+
Name string
11
+
Path string
12
+
IsDirectory bool
13
+
Children map[string]*FileTreeNode
14
+
}
15
+
16
+
// NewNode creates a new node
17
+
func newNode(name, path string, isDir bool) *FileTreeNode {
18
+
return &FileTreeNode{
19
+
Name: name,
20
+
Path: path,
21
+
IsDirectory: isDir,
22
+
Children: make(map[string]*FileTreeNode),
23
+
}
24
+
}
25
+
26
+
func FileTree(files []string) *FileTreeNode {
27
+
rootNode := newNode("", "", true)
28
+
29
+
sort.Strings(files)
30
+
31
+
for _, file := range files {
32
+
if file == "" {
33
+
continue
34
+
}
35
+
36
+
parts := strings.Split(filepath.Clean(file), "/")
37
+
if len(parts) == 0 {
38
+
continue
39
+
}
40
+
41
+
currentNode := rootNode
42
+
currentPath := ""
43
+
44
+
for i, part := range parts {
45
+
if currentPath == "" {
46
+
currentPath = part
47
+
} else {
48
+
currentPath = filepath.Join(currentPath, part)
49
+
}
50
+
51
+
isDir := i < len(parts)-1
52
+
53
+
if _, exists := currentNode.Children[part]; !exists {
54
+
currentNode.Children[part] = newNode(part, currentPath, isDir)
55
+
}
56
+
57
+
currentNode = currentNode.Children[part]
58
+
}
59
+
}
60
+
61
+
return rootNode
62
+
}
+126
appview/middleware/middleware.go
+126
appview/middleware/middleware.go
···
1
+
package middleware
2
+
3
+
import (
4
+
"context"
5
+
"log"
6
+
"net/http"
7
+
"strconv"
8
+
"time"
9
+
10
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/xrpc"
12
+
"tangled.sh/tangled.sh/core/appview"
13
+
"tangled.sh/tangled.sh/core/appview/auth"
14
+
"tangled.sh/tangled.sh/core/appview/pagination"
15
+
)
16
+
17
+
type Middleware func(http.Handler) http.Handler
18
+
19
+
func AuthMiddleware(a *auth.Auth) Middleware {
20
+
return func(next http.Handler) http.Handler {
21
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22
+
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
23
+
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
24
+
}
25
+
if r.Header.Get("HX-Request") == "true" {
26
+
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
27
+
w.Header().Set("HX-Redirect", "/login")
28
+
w.WriteHeader(http.StatusOK)
29
+
}
30
+
}
31
+
32
+
session, err := a.GetSession(r)
33
+
if session.IsNew || err != nil {
34
+
log.Printf("not logged in, redirecting")
35
+
redirectFunc(w, r)
36
+
return
37
+
}
38
+
39
+
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
40
+
if !ok || !authorized {
41
+
log.Printf("not logged in, redirecting")
42
+
redirectFunc(w, r)
43
+
return
44
+
}
45
+
46
+
// refresh if nearing expiry
47
+
// TODO: dedup with /login
48
+
expiryStr := session.Values[appview.SessionExpiry].(string)
49
+
expiry, err := time.Parse(time.RFC3339, expiryStr)
50
+
if err != nil {
51
+
log.Println("invalid expiry time", err)
52
+
redirectFunc(w, r)
53
+
return
54
+
}
55
+
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
56
+
did, ok2 := session.Values[appview.SessionDid].(string)
57
+
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
58
+
59
+
if !ok1 || !ok2 || !ok3 {
60
+
log.Println("invalid expiry time", err)
61
+
redirectFunc(w, r)
62
+
return
63
+
}
64
+
65
+
if time.Now().After(expiry) {
66
+
log.Println("token expired, refreshing ...")
67
+
68
+
client := xrpc.Client{
69
+
Host: pdsUrl,
70
+
Auth: &xrpc.AuthInfo{
71
+
Did: did,
72
+
AccessJwt: refreshJwt,
73
+
RefreshJwt: refreshJwt,
74
+
},
75
+
}
76
+
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
77
+
if err != nil {
78
+
log.Println("failed to refresh session", err)
79
+
redirectFunc(w, r)
80
+
return
81
+
}
82
+
83
+
sessionish := auth.RefreshSessionWrapper{atSession}
84
+
85
+
err = a.StoreSession(r, w, &sessionish, pdsUrl)
86
+
if err != nil {
87
+
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
88
+
return
89
+
}
90
+
91
+
log.Println("successfully refreshed token")
92
+
}
93
+
94
+
next.ServeHTTP(w, r)
95
+
})
96
+
}
97
+
}
98
+
99
+
func Paginate(next http.Handler) http.Handler {
100
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101
+
page := pagination.FirstPage()
102
+
103
+
offsetVal := r.URL.Query().Get("offset")
104
+
if offsetVal != "" {
105
+
offset, err := strconv.Atoi(offsetVal)
106
+
if err != nil {
107
+
log.Println("invalid offset")
108
+
} else {
109
+
page.Offset = offset
110
+
}
111
+
}
112
+
113
+
limitVal := r.URL.Query().Get("limit")
114
+
if limitVal != "" {
115
+
limit, err := strconv.Atoi(limitVal)
116
+
if err != nil {
117
+
log.Println("invalid limit")
118
+
} else {
119
+
page.Limit = limit
120
+
}
121
+
}
122
+
123
+
ctx := context.WithValue(r.Context(), "page", page)
124
+
next.ServeHTTP(w, r.WithContext(ctx))
125
+
})
126
+
}
-40
appview/pages/chroma.go
-40
appview/pages/chroma.go
···
1
-
package pages
2
-
3
-
import "github.com/alecthomas/chroma/v2"
4
-
5
-
var tangledTheme map[chroma.TokenType]string = map[chroma.TokenType]string{
6
-
// Keywords
7
-
chroma.Keyword: "text-blue-400",
8
-
chroma.KeywordConstant: "text-indigo-400",
9
-
chroma.KeywordDeclaration: "text-purple-400",
10
-
chroma.KeywordNamespace: "text-teal-400",
11
-
chroma.KeywordReserved: "text-pink-400",
12
-
13
-
// Names
14
-
chroma.Name: "text-gray-700",
15
-
chroma.NameFunction: "text-green-500",
16
-
chroma.NameClass: "text-orange-400",
17
-
chroma.NameNamespace: "text-cyan-500",
18
-
chroma.NameVariable: "text-red-400",
19
-
chroma.NameBuiltin: "text-yellow-500",
20
-
21
-
// Literals
22
-
chroma.LiteralString: "text-emerald-500 ",
23
-
chroma.LiteralStringChar: "text-lime-500",
24
-
chroma.LiteralNumber: "text-rose-400",
25
-
chroma.LiteralNumberFloat: "text-amber-500",
26
-
27
-
// Operators
28
-
chroma.Operator: "text-blue-500",
29
-
chroma.OperatorWord: "text-indigo-500",
30
-
31
-
// Comments
32
-
chroma.Comment: "text-gray-500 italic",
33
-
chroma.CommentSingle: "text-gray-400 italic",
34
-
35
-
// Generic
36
-
chroma.GenericError: "text-red-600",
37
-
chroma.GenericHeading: "text-purple-500 font-bold",
38
-
chroma.GenericDeleted: "text-red-400 line-through",
39
-
chroma.GenericInserted: "text-green-400 underline",
40
-
}
+2
appview/pages/funcmap.go
+2
appview/pages/funcmap.go
···
13
13
"time"
14
14
15
15
"github.com/dustin/go-humanize"
16
+
"tangled.sh/tangled.sh/core/appview/filetree"
16
17
"tangled.sh/tangled.sh/core/appview/pages/markup"
17
18
)
18
19
···
174
175
return template.HTML(data)
175
176
},
176
177
"cssContentHash": CssContentHash,
178
+
"fileTree": filetree.FileTree,
177
179
}
178
180
}
179
181
+159
-54
appview/pages/pages.go
+159
-54
appview/pages/pages.go
···
11
11
"io/fs"
12
12
"log"
13
13
"net/http"
14
+
"os"
14
15
"path"
15
16
"path/filepath"
16
17
"slices"
17
18
"strings"
18
19
20
+
"tangled.sh/tangled.sh/core/appview/auth"
21
+
"tangled.sh/tangled.sh/core/appview/db"
22
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
23
+
"tangled.sh/tangled.sh/core/appview/pagination"
24
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
25
+
"tangled.sh/tangled.sh/core/patchutil"
26
+
"tangled.sh/tangled.sh/core/types"
27
+
19
28
"github.com/alecthomas/chroma/v2"
20
29
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
21
30
"github.com/alecthomas/chroma/v2/lexers"
22
31
"github.com/alecthomas/chroma/v2/styles"
23
32
"github.com/bluesky-social/indigo/atproto/syntax"
24
33
"github.com/microcosm-cc/bluemonday"
25
-
"tangled.sh/tangled.sh/core/appview/auth"
26
-
"tangled.sh/tangled.sh/core/appview/db"
27
-
"tangled.sh/tangled.sh/core/appview/pages/markup"
28
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
29
-
"tangled.sh/tangled.sh/core/types"
30
34
)
31
35
32
36
//go:embed templates/* static
33
37
var Files embed.FS
34
38
35
39
type Pages struct {
36
-
t map[string]*template.Template
40
+
t map[string]*template.Template
41
+
dev bool
42
+
embedFS embed.FS
43
+
templateDir string // Path to templates on disk for dev mode
37
44
}
38
45
39
-
func NewPages() *Pages {
40
-
templates := make(map[string]*template.Template)
46
+
func NewPages(dev bool) *Pages {
47
+
p := &Pages{
48
+
t: make(map[string]*template.Template),
49
+
dev: dev,
50
+
embedFS: Files,
51
+
templateDir: "appview/pages",
52
+
}
53
+
54
+
// Initial load of all templates
55
+
p.loadAllTemplates()
56
+
57
+
return p
58
+
}
41
59
60
+
func (p *Pages) loadAllTemplates() {
61
+
templates := make(map[string]*template.Template)
42
62
var fragmentPaths []string
63
+
64
+
// Use embedded FS for initial loading
43
65
// First, collect all fragment paths
44
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
66
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
45
67
if err != nil {
46
68
return err
47
69
}
48
-
49
70
if d.IsDir() {
50
71
return nil
51
72
}
52
-
53
73
if !strings.HasSuffix(path, ".html") {
54
74
return nil
55
75
}
56
-
57
76
if !strings.Contains(path, "fragments/") {
58
77
return nil
59
78
}
60
-
61
79
name := strings.TrimPrefix(path, "templates/")
62
80
name = strings.TrimSuffix(name, ".html")
63
-
64
81
tmpl, err := template.New(name).
65
82
Funcs(funcMap()).
66
-
ParseFS(Files, path)
83
+
ParseFS(p.embedFS, path)
67
84
if err != nil {
68
85
log.Fatalf("setting up fragment: %v", err)
69
86
}
70
-
71
87
templates[name] = tmpl
72
88
fragmentPaths = append(fragmentPaths, path)
73
89
log.Printf("loaded fragment: %s", name)
···
78
94
}
79
95
80
96
// Then walk through and setup the rest of the templates
81
-
err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
97
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
82
98
if err != nil {
83
99
return err
84
100
}
85
-
86
101
if d.IsDir() {
87
102
return nil
88
103
}
89
-
90
104
if !strings.HasSuffix(path, "html") {
91
105
return nil
92
106
}
93
-
94
107
// Skip fragments as they've already been loaded
95
108
if strings.Contains(path, "fragments/") {
96
109
return nil
97
110
}
98
-
99
111
// Skip layouts
100
112
if strings.Contains(path, "layouts/") {
101
113
return nil
102
114
}
103
-
104
115
name := strings.TrimPrefix(path, "templates/")
105
116
name = strings.TrimSuffix(name, ".html")
106
-
107
117
// Add the page template on top of the base
108
118
allPaths := []string{}
109
119
allPaths = append(allPaths, "templates/layouts/*.html")
···
111
121
allPaths = append(allPaths, path)
112
122
tmpl, err := template.New(name).
113
123
Funcs(funcMap()).
114
-
ParseFS(Files, allPaths...)
124
+
ParseFS(p.embedFS, allPaths...)
115
125
if err != nil {
116
126
return fmt.Errorf("setting up template: %w", err)
117
127
}
118
-
119
128
templates[name] = tmpl
120
129
log.Printf("loaded template: %s", name)
121
130
return nil
···
125
134
}
126
135
127
136
log.Printf("total templates loaded: %d", len(templates))
137
+
p.t = templates
138
+
}
139
+
140
+
// loadTemplateFromDisk loads a template from the filesystem in dev mode
141
+
func (p *Pages) loadTemplateFromDisk(name string) error {
142
+
if !p.dev {
143
+
return nil
144
+
}
145
+
146
+
log.Printf("reloading template from disk: %s", name)
147
+
148
+
// Find all fragments first
149
+
var fragmentPaths []string
150
+
err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error {
151
+
if err != nil {
152
+
return err
153
+
}
154
+
if d.IsDir() {
155
+
return nil
156
+
}
157
+
if !strings.HasSuffix(path, ".html") {
158
+
return nil
159
+
}
160
+
if !strings.Contains(path, "fragments/") {
161
+
return nil
162
+
}
163
+
fragmentPaths = append(fragmentPaths, path)
164
+
return nil
165
+
})
166
+
if err != nil {
167
+
return fmt.Errorf("walking disk template dir for fragments: %w", err)
168
+
}
169
+
170
+
// Find the template path on disk
171
+
templatePath := filepath.Join(p.templateDir, "templates", name+".html")
172
+
if _, err := os.Stat(templatePath); os.IsNotExist(err) {
173
+
return fmt.Errorf("template not found on disk: %s", name)
174
+
}
175
+
176
+
// Create a new template
177
+
tmpl := template.New(name).Funcs(funcMap())
178
+
179
+
// Parse layouts
180
+
layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html")
181
+
layouts, err := filepath.Glob(layoutGlob)
182
+
if err != nil {
183
+
return fmt.Errorf("finding layout templates: %w", err)
184
+
}
128
185
129
-
return &Pages{
130
-
t: templates,
186
+
// Create paths for parsing
187
+
allFiles := append(layouts, fragmentPaths...)
188
+
allFiles = append(allFiles, templatePath)
189
+
190
+
// Parse all templates
191
+
tmpl, err = tmpl.ParseFiles(allFiles...)
192
+
if err != nil {
193
+
return fmt.Errorf("parsing template files: %w", err)
131
194
}
195
+
196
+
// Update the template in the map
197
+
p.t[name] = tmpl
198
+
log.Printf("template reloaded from disk: %s", name)
199
+
return nil
132
200
}
133
201
134
-
type LoginParams struct {
202
+
func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error {
203
+
// In dev mode, reload the template from disk before executing
204
+
if p.dev {
205
+
if err := p.loadTemplateFromDisk(templateName); err != nil {
206
+
log.Printf("warning: failed to reload template %s from disk: %v", templateName, err)
207
+
// Continue with the existing template
208
+
}
209
+
}
210
+
211
+
tmpl, exists := p.t[templateName]
212
+
if !exists {
213
+
return fmt.Errorf("template not found: %s", templateName)
214
+
}
215
+
216
+
if base == "" {
217
+
return tmpl.Execute(w, params)
218
+
} else {
219
+
return tmpl.ExecuteTemplate(w, base, params)
220
+
}
135
221
}
136
222
137
223
func (p *Pages) execute(name string, w io.Writer, params any) error {
138
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
224
+
return p.executeOrReload(name, w, "layouts/base", params)
139
225
}
140
226
141
227
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
142
-
return p.t[name].Execute(w, params)
228
+
return p.executeOrReload(name, w, "", params)
143
229
}
144
230
145
231
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
146
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
232
+
return p.executeOrReload(name, w, "layouts/repobase", params)
233
+
}
234
+
235
+
type LoginParams struct {
147
236
}
148
237
149
238
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
417
506
}
418
507
419
508
type RepoCommitParams struct {
420
-
LoggedInUser *auth.User
421
-
RepoInfo RepoInfo
422
-
Active string
509
+
LoggedInUser *auth.User
510
+
RepoInfo RepoInfo
511
+
Active string
512
+
EmailToDidOrHandle map[string]string
513
+
423
514
types.RepoCommitResponse
424
-
EmailToDidOrHandle map[string]string
425
515
}
426
516
427
517
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
497
587
}
498
588
499
589
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
500
-
style := styles.Get("bw")
501
-
b := style.Builder()
502
-
b.Add(chroma.LiteralString, "noitalic")
503
-
style, _ = b.Build()
590
+
var style *chroma.Style = styles.Get("catpuccin-latte")
504
591
505
592
if params.ShowRendered {
506
593
switch markup.GetFormat(params.Path) {
···
516
603
chromahtml.WithLineNumbers(true),
517
604
chromahtml.WithLinkableLineNumbers(true, "L"),
518
605
chromahtml.Standalone(false),
606
+
chromahtml.WithClasses(true),
519
607
)
520
608
521
609
lexer := lexers.Get(filepath.Base(params.Path))
···
564
652
}
565
653
566
654
type RepoIssuesParams struct {
567
-
LoggedInUser *auth.User
568
-
RepoInfo RepoInfo
569
-
Active string
570
-
Issues []db.Issue
571
-
DidHandleMap map[string]string
572
-
655
+
LoggedInUser *auth.User
656
+
RepoInfo RepoInfo
657
+
Active string
658
+
Issues []db.Issue
659
+
DidHandleMap map[string]string
660
+
Page pagination.Page
573
661
FilteringByOpen bool
574
662
}
575
663
···
679
767
}
680
768
681
769
type RepoSinglePullParams struct {
682
-
LoggedInUser *auth.User
683
-
RepoInfo RepoInfo
684
-
Active string
685
-
DidHandleMap map[string]string
686
-
Pull *db.Pull
687
-
PullSourceRepo *db.Repo
688
-
MergeCheck types.MergeCheckResponse
689
-
ResubmitCheck ResubmitResult
770
+
LoggedInUser *auth.User
771
+
RepoInfo RepoInfo
772
+
Active string
773
+
DidHandleMap map[string]string
774
+
Pull *db.Pull
775
+
MergeCheck types.MergeCheckResponse
776
+
ResubmitCheck ResubmitResult
690
777
}
691
778
692
779
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
699
786
DidHandleMap map[string]string
700
787
RepoInfo RepoInfo
701
788
Pull *db.Pull
702
-
Diff types.NiceDiff
789
+
Diff *types.NiceDiff
703
790
Round int
704
791
Submission *db.PullSubmission
705
792
}
···
707
794
// this name is a mouthful
708
795
func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
709
796
return p.execute("repo/pulls/patch", w, params)
797
+
}
798
+
799
+
type RepoPullInterdiffParams struct {
800
+
LoggedInUser *auth.User
801
+
DidHandleMap map[string]string
802
+
RepoInfo RepoInfo
803
+
Pull *db.Pull
804
+
Round int
805
+
Interdiff *patchutil.InterdiffResult
806
+
}
807
+
808
+
// this name is a mouthful
809
+
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
810
+
return p.execute("repo/pulls/interdiff", w, params)
710
811
}
711
812
712
813
type PullPatchUploadParams struct {
···
781
882
}
782
883
783
884
func (p *Pages) Static() http.Handler {
885
+
if p.dev {
886
+
return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
887
+
}
888
+
784
889
sub, err := fs.Sub(Files, "static")
785
890
if err != nil {
786
891
log.Fatalf("no static dir found? that's crazy: %v", err)
+4
-17
appview/pages/templates/repo/fragments/diff.html
+4
-17
appview/pages/templates/repo/fragments/diff.html
···
3
3
{{ $diff := index . 1 }}
4
4
{{ $commit := $diff.Commit }}
5
5
{{ $stat := $diff.Stat }}
6
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
6
7
{{ $diff := $diff.Diff }}
7
8
8
9
{{ $this := $commit.This }}
···
14
15
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
15
16
{{ block "statPill" $stat }} {{ end }}
16
17
</div>
17
-
<div class="overflow-x-auto">
18
-
{{ range $diff }}
19
-
<ul class="dark:text-gray-200">
20
-
{{ if .IsDelete }}
21
-
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
22
-
{{ else }}
23
-
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
24
-
{{ end }}
25
-
</ul>
26
-
{{ end }}
27
-
</div>
18
+
{{ block "fileTree" $fileTree }} {{ end }}
28
19
</div>
29
20
</section>
30
21
···
38
29
<summary class="list-none cursor-pointer sticky top-0">
39
30
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
40
31
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
41
-
<div class="flex gap-1 items-center" style="direction: ltr;">
32
+
<div class="flex gap-1 items-center">
42
33
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
43
34
{{ if .IsNew }}
44
35
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
···
55
46
{{ block "statPill" .Stats }} {{ end }}
56
47
</div>
57
48
58
-
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
49
+
<div class="flex gap-2 items-center overflow-x-auto">
59
50
{{ if .IsDelete }}
60
51
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
61
52
{{ .Name.Old }}
···
101
92
{{ else if .IsCopy }}
102
93
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
103
94
This file has been copied.
104
-
</p>
105
-
{{ else if .IsRename }}
106
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
107
-
This file has been renamed.
108
95
</p>
109
96
{{ else if .IsBinary }}
110
97
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
+27
appview/pages/templates/repo/fragments/filetree.html
+27
appview/pages/templates/repo/fragments/filetree.html
···
1
+
{{ define "fileTree" }}
2
+
{{ if and .Name .IsDirectory }}
3
+
<details open>
4
+
<summary class="cursor-pointer list-none pt-1">
5
+
<span class="inline-flex items-center gap-2 ">
6
+
{{ i "folder" "w-3 h-3 fill-current" }}
7
+
<span class="text-black dark:text-white">{{ .Name }}</span>
8
+
</span>
9
+
</summary>
10
+
<div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700">
11
+
{{ range $child := .Children }}
12
+
{{ block "fileTree" $child }} {{ end }}
13
+
{{ end }}
14
+
</div>
15
+
</details>
16
+
{{ else if .Name }}
17
+
<div class="flex items-center gap-2 pt-1">
18
+
{{ i "file" "w-3 h-3" }}
19
+
<a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a>
20
+
</div>
21
+
{{ else }}
22
+
{{ range $child := .Children }}
23
+
{{ block "fileTree" $child }} {{ end }}
24
+
{{ end }}
25
+
{{ end }}
26
+
{{ end }}
27
+
+143
appview/pages/templates/repo/fragments/interdiff.html
+143
appview/pages/templates/repo/fragments/interdiff.html
···
1
+
{{ define "repo/fragments/interdiff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $x := index . 1 }}
4
+
{{ $fileTree := fileTree $x.AffectedFiles }}
5
+
{{ $diff := $x.Files }}
6
+
7
+
<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">
8
+
<div class="diff-stat">
9
+
<div class="flex gap-2 items-center">
10
+
<strong class="text-sm uppercase dark:text-gray-200">files</strong>
11
+
</div>
12
+
{{ block "fileTree" $fileTree }} {{ end }}
13
+
</div>
14
+
</section>
15
+
16
+
{{ $last := sub (len $diff) 1 }}
17
+
{{ range $idx, $hunk := $diff }}
18
+
{{ with $hunk }}
19
+
<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">
20
+
<div id="file-{{ .Name }}">
21
+
<div id="diff-file">
22
+
<details {{ if not (.Status.IsOnlyInOne) }}open{{end}}>
23
+
<summary class="list-none cursor-pointer sticky top-0">
24
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
25
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
26
+
<div class="flex gap-1 items-center" style="direction: ltr;">
27
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
28
+
{{ if .Status.IsOk }}
29
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span>
30
+
{{ else if .Status.IsUnchanged }}
31
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span>
32
+
{{ else if .Status.IsOnlyInOne }}
33
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span>
34
+
{{ else if .Status.IsOnlyInTwo }}
35
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span>
36
+
{{ else if .Status.IsRebased }}
37
+
<span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span>
38
+
{{ else }}
39
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span>
40
+
{{ end }}
41
+
</div>
42
+
43
+
<div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
44
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" href="">
45
+
{{ .Name }}
46
+
</a>
47
+
</div>
48
+
</div>
49
+
50
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
51
+
<div id="right-side-items" class="p-2 flex items-center">
52
+
<a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
53
+
{{ if gt $idx 0 }}
54
+
{{ $prev := index $diff (sub $idx 1) }}
55
+
<a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
56
+
{{ end }}
57
+
58
+
{{ if lt $idx $last }}
59
+
{{ $next := index $diff (add $idx 1) }}
60
+
<a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
61
+
{{ end }}
62
+
</div>
63
+
64
+
</div>
65
+
</summary>
66
+
67
+
<div class="transition-all duration-700 ease-in-out">
68
+
{{ if .Status.IsUnchanged }}
69
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
70
+
This file has not been changed.
71
+
</p>
72
+
{{ else if .Status.IsRebased }}
73
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
74
+
This patch was likely rebased, as context lines do not match.
75
+
</p>
76
+
{{ else if .Status.IsError }}
77
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
78
+
Failed to calculate interdiff for this file.
79
+
</p>
80
+
{{ else }}
81
+
{{ $name := .Name }}
82
+
<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">···</div>
83
+
{{- $oldStart := .OldPosition -}}
84
+
{{- $newStart := .NewPosition -}}
85
+
{{- $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 " -}}
86
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
87
+
{{- $lineNrSepStyle1 := "" -}}
88
+
{{- $lineNrSepStyle2 := "pr-2" -}}
89
+
{{- range .Lines -}}
90
+
{{- if eq .Op.String "+" -}}
91
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
92
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
93
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
94
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
95
+
<div class="px-2">{{ .Line }}</div>
96
+
</div>
97
+
{{- $newStart = add64 $newStart 1 -}}
98
+
{{- end -}}
99
+
{{- if eq .Op.String "-" -}}
100
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
101
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
102
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
103
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
104
+
<div class="px-2">{{ .Line }}</div>
105
+
</div>
106
+
{{- $oldStart = add64 $oldStart 1 -}}
107
+
{{- end -}}
108
+
{{- if eq .Op.String " " -}}
109
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
110
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
111
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
112
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
113
+
<div class="px-2">{{ .Line }}</div>
114
+
</div>
115
+
{{- $newStart = add64 $newStart 1 -}}
116
+
{{- $oldStart = add64 $oldStart 1 -}}
117
+
{{- end -}}
118
+
{{- end -}}
119
+
{{- end -}}</div></div></pre>
120
+
{{- end -}}
121
+
</div>
122
+
123
+
</details>
124
+
125
+
</div>
126
+
</div>
127
+
</section>
128
+
{{ end }}
129
+
{{ end }}
130
+
{{ end }}
131
+
132
+
{{ define "statPill" }}
133
+
<div class="flex items-center font-mono text-sm">
134
+
{{ if and .Insertions .Deletions }}
135
+
<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>
136
+
<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>
137
+
{{ else if .Insertions }}
138
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
139
+
{{ else if .Deletions }}
140
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
141
+
{{ end }}
142
+
</div>
143
+
{{ end }}
+38
appview/pages/templates/repo/issues/issues.html
+38
appview/pages/templates/repo/issues/issues.html
···
70
70
</div>
71
71
{{ end }}
72
72
</div>
73
+
74
+
{{ block "pagination" . }} {{ end }}
75
+
76
+
{{ end }}
77
+
78
+
{{ define "pagination" }}
79
+
<div class="flex justify-end mt-4 gap-2">
80
+
{{ $currentState := "closed" }}
81
+
{{ if .FilteringByOpen }}
82
+
{{ $currentState = "open" }}
83
+
{{ end }}
84
+
85
+
{{ if gt .Page.Offset 0 }}
86
+
{{ $prev := .Page.Previous }}
87
+
<a
88
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
89
+
hx-boost="true"
90
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}"
91
+
>
92
+
{{ i "chevron-left" "w-4 h-4" }}
93
+
previous
94
+
</a>
95
+
{{ else }}
96
+
<div></div>
97
+
{{ end }}
98
+
99
+
{{ if eq (len .Issues) .Page.Limit }}
100
+
{{ $next := .Page.Next }}
101
+
<a
102
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
103
+
hx-boost="true"
104
+
href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}"
105
+
>
106
+
next
107
+
{{ i "chevron-right" "w-4 h-4" }}
108
+
</a>
109
+
{{ end }}
110
+
</div>
73
111
{{ end }}
+5
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+5
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+4
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+4
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
1
+
{{ define "repo/pulls/fragments/pullHeader" }}
2
+
<header class="pb-4">
3
+
<h1 class="text-2xl dark:text-white">
4
+
{{ .Pull.Title }}
5
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
6
+
</h1>
7
+
</header>
8
+
9
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
10
+
{{ $icon := "ban" }}
11
+
12
+
{{ if .Pull.State.IsOpen }}
13
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
14
+
{{ $icon = "git-pull-request" }}
15
+
{{ else if .Pull.State.IsMerged }}
16
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
17
+
{{ $icon = "git-merge" }}
18
+
{{ end }}
19
+
20
+
<section class="mt-2">
21
+
<div class="flex items-center gap-2">
22
+
<div
23
+
id="state"
24
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
25
+
>
26
+
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
27
+
<span class="text-white">{{ .Pull.State.String }}</span>
28
+
</div>
29
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
30
+
opened by
31
+
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
32
+
<a href="/{{ $owner }}" class="no-underline hover:underline"
33
+
>{{ $owner }}</a
34
+
>
35
+
<span class="select-none before:content-['\00B7']"></span>
36
+
<time>{{ .Pull.Created | timeFmt }}</time>
37
+
<span class="select-none before:content-['\00B7']"></span>
38
+
<span>
39
+
targeting
40
+
<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">
41
+
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
42
+
</span>
43
+
</span>
44
+
{{ if not .Pull.IsPatchBased }}
45
+
<span>from
46
+
{{ if .Pull.IsForkBased }}
47
+
{{ if .Pull.PullSource.Repo }}
48
+
<a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>
49
+
{{ else }}
50
+
<span class="italic">[deleted fork]</span>
51
+
{{ end }}
52
+
{{ end }}
53
+
54
+
<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">
55
+
{{ .Pull.PullSource.Branch }}
56
+
</span>
57
+
</span>
58
+
{{ end }}
59
+
</span>
60
+
</div>
61
+
62
+
{{ if .Pull.Body }}
63
+
<article id="body" class="mt-8 prose dark:prose-invert">
64
+
{{ .Pull.Body | markdown }}
65
+
</article>
66
+
{{ end }}
67
+
</section>
68
+
69
+
70
+
{{ end }}
+11
-4
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
+11
-4
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
1
1
{{ define "repo/pulls/fragments/pullPatchUpload" }}
2
2
<div id="patch-upload">
3
+
<p>
4
+
You can paste a <code>git diff</code> or a
5
+
<code>git format-patch</code> patch series here.
6
+
</p>
3
7
<textarea
8
+
hx-trigger="keyup changed delay:500ms, paste delay:500ms"
9
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch"
10
+
hx-swap="none"
4
11
name="patch"
5
12
id="patch"
6
13
rows="12"
7
-
class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
14
+
class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
8
15
placeholder="diff --git a/file.txt b/file.txt
9
-
index 1234567..abcdefg 100644
10
-
--- a/file.txt
11
-
+++ b/file.txt"
16
+
index 1234567..abcdefg 100644
17
+
--- a/file.txt
18
+
+++ b/file.txt"
12
19
></textarea>
13
20
</div>
14
21
{{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
+25
appview/pages/templates/repo/pulls/interdiff.html
···
1
+
{{ define "title" }}
2
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
3
+
{{ end }}
4
+
5
+
{{ define "content" }}
6
+
<section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
7
+
<header class="pb-2">
8
+
<div class="flex gap-3 items-center mb-3">
9
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
10
+
{{ i "arrow-left" "w-5 h-5" }}
11
+
back
12
+
</a>
13
+
<span class="select-none before:content-['\00B7']"></span>
14
+
interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}
15
+
</div>
16
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
17
+
{{ template "repo/pulls/fragments/pullHeader" . }}
18
+
</header>
19
+
</section>
20
+
21
+
<section>
22
+
{{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }}
23
+
</section>
24
+
{{ end }}
25
+
+90
-81
appview/pages/templates/repo/pulls/new.html
+90
-81
appview/pages/templates/repo/pulls/new.html
···
7
7
hx-swap="none"
8
8
>
9
9
<div class="flex flex-col gap-4">
10
-
<div>
11
-
<label for="title" class="dark:text-white">write a title</label>
12
-
<input type="text" name="title" id="title" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" />
13
-
</div>
10
+
<label>configure your pull request</label>
14
11
15
-
<div>
16
-
<label for="body" class="dark:text-white">add a description</label>
17
-
<textarea
18
-
name="body"
19
-
id="body"
20
-
rows="6"
21
-
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
22
-
placeholder="Describe your change. Markdown is supported."
23
-
></textarea>
24
-
</div>
12
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
13
+
<div class="pb-2">
14
+
<select
15
+
required
16
+
name="targetBranch"
17
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
18
+
>
19
+
<option disabled selected>target branch</option>
20
+
{{ range .Branches }}
21
+
<option value="{{ .Reference.Name }}" class="py-1">
22
+
{{ .Reference.Name }}
23
+
</option>
24
+
{{ end }}
25
+
</select>
26
+
</div>
25
27
28
+
<p>Next, choose a pull strategy.</p>
29
+
<nav class="flex space-x-4 items-end">
30
+
<button
31
+
type="button"
32
+
class="px-3 py-2 pb-2 btn"
33
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
34
+
hx-target="#patch-strategy"
35
+
hx-swap="innerHTML"
36
+
>
37
+
paste patch
38
+
</button>
26
39
27
-
<label>configure your pull request</label>
40
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
41
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
42
+
or
43
+
</span>
44
+
<button
45
+
type="button"
46
+
class="px-3 py-2 pb-2 btn"
47
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
48
+
hx-target="#patch-strategy"
49
+
hx-swap="innerHTML"
50
+
>
51
+
compare branches
52
+
</button>
53
+
{{ end }}
28
54
29
-
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
30
-
<div class="pb-2">
31
-
<select
32
-
required
33
-
name="targetBranch"
34
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
35
-
>
36
-
<option disabled selected>target branch</option>
37
-
{{ range .Branches }}
38
-
<option value="{{ .Reference.Name }}" class="py-1">
39
-
{{ .Reference.Name }}
40
-
</option>
41
-
{{ end }}
42
-
</select>
43
-
</div>
55
+
56
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
57
+
or
58
+
</span>
59
+
<button
60
+
type="button"
61
+
class="px-3 py-2 pb-2 btn"
62
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
63
+
hx-target="#patch-strategy"
64
+
hx-swap="innerHTML"
65
+
>
66
+
compare forks
67
+
</button>
68
+
</nav>
69
+
70
+
<section id="patch-strategy">
71
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
72
+
</section>
73
+
74
+
<p id="patch-preview"></p>
44
75
45
-
<p>Then, choose a pull strategy.</p>
46
-
<nav class="flex space-x-4 items-end">
47
-
<button
48
-
type="button"
49
-
class="px-3 py-2 pb-2 btn"
50
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
51
-
hx-target="#patch-strategy"
52
-
hx-swap="innerHTML"
53
-
>
54
-
paste patch
55
-
</button>
76
+
<div id="patch-error" class="error dark:text-red-300"></div>
56
77
57
-
{{ if .RepoInfo.Roles.IsPushAllowed }}
58
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
59
-
or
60
-
</span>
61
-
<button
62
-
type="button"
63
-
class="px-3 py-2 pb-2 btn"
64
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
65
-
hx-target="#patch-strategy"
66
-
hx-swap="innerHTML"
67
-
>
68
-
compare branches
69
-
</button>
70
-
{{ end }}
78
+
<div>
79
+
<label for="title" class="dark:text-white">write a title</label>
71
80
72
-
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
73
-
or
74
-
</span>
75
-
<button
76
-
type="button"
77
-
class="px-3 py-2 pb-2 btn"
78
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
79
-
hx-target="#patch-strategy"
80
-
hx-swap="innerHTML"
81
-
>
82
-
compare forks
83
-
</button>
84
-
</nav>
81
+
<input
82
+
type="text"
83
+
name="title"
84
+
id="title"
85
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600"
86
+
placeholder="One-line summary of your change."
87
+
/>
88
+
</div>
85
89
86
-
<section id="patch-strategy">
87
-
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
88
-
</section>
90
+
<div>
91
+
<label for="body" class="dark:text-white"
92
+
>add a description</label
93
+
>
89
94
90
-
<div class="flex justify-start items-center gap-2 mt-4">
91
-
<button type="submit" class="btn flex items-center gap-2">
92
-
{{ i "git-pull-request-create" "w-4 h-4" }}
93
-
create pull
94
-
</button>
95
-
</div>
95
+
<textarea
96
+
name="body"
97
+
id="body"
98
+
rows="6"
99
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
100
+
placeholder="Describe your change. Markdown is supported."
101
+
></textarea>
102
+
</div>
96
103
104
+
<div class="flex justify-start items-center gap-2 mt-4">
105
+
<button type="submit" class="btn flex items-center gap-2">
106
+
{{ i "git-pull-request-create" "w-4 h-4" }}
107
+
create pull
108
+
</button>
109
+
</div>
97
110
</div>
98
111
<div id="pull" class="error dark:text-red-300"></div>
99
112
</form>
100
113
{{ end }}
101
-
102
-
{{ define "repoAfter" }}
103
-
<div id="patch-preview" class="error dark:text-red-300"></div>
104
-
{{ end }}
+21
-71
appview/pages/templates/repo/pulls/patch.html
+21
-71
appview/pages/templates/repo/pulls/patch.html
···
3
3
{{ end }}
4
4
5
5
{{ define "content" }}
6
-
{{ $stat := .Diff.Stat }}
7
-
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
8
-
<header class="pb-2">
9
-
<div class="flex gap-3 items-center mb-3">
10
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
11
-
{{ i "arrow-left" "w-5 h-5" }}
12
-
back
13
-
</a>
14
-
<span class="select-none before:content-['\00B7']"></span>
15
-
round #{{ .Round }}
16
-
<span class="select-none before:content-['\00B7']"></span>
17
-
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
18
-
view raw
19
-
</a>
20
-
</div>
21
-
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
22
-
<h1 class="text-2xl mt-3">
23
-
{{ .Pull.Title }}
24
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
25
-
</h1>
26
-
</header>
27
-
28
-
{{ $bgColor := "bg-gray-800" }}
29
-
{{ $icon := "ban" }}
30
-
31
-
{{ if .Pull.State.IsOpen }}
32
-
{{ $bgColor = "bg-green-600" }}
33
-
{{ $icon = "git-pull-request" }}
34
-
{{ else if .Pull.State.IsMerged }}
35
-
{{ $bgColor = "bg-purple-600" }}
36
-
{{ $icon = "git-merge" }}
37
-
{{ end }}
38
-
39
-
<section>
40
-
<div class="flex items-center gap-2">
41
-
<div
42
-
id="state"
43
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
44
-
>
45
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
46
-
<span class="text-white">{{ .Pull.State.String }}</span>
47
-
</div>
48
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
49
-
opened by
50
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
51
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
52
-
>{{ $owner }}</a
53
-
>
54
-
<span class="select-none before:content-['\00B7']"></span>
55
-
<time>{{ .Pull.Created | timeFmt }}</time>
56
-
<span class="select-none before:content-['\00B7']"></span>
57
-
<span>targeting branch
58
-
<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">
59
-
{{ .Pull.TargetBranch }}
60
-
</span>
61
-
</span>
62
-
</span>
63
-
</div>
64
-
65
-
{{ if .Pull.Body }}
66
-
<article id="body" class="mt-2 prose dark:prose-invert">
67
-
{{ .Pull.Body | markdown }}
68
-
</article>
69
-
{{ end }}
70
-
</section>
71
-
72
-
</div>
73
-
74
-
<section>
75
-
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
76
-
</section>
6
+
<section>
7
+
<section
8
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
9
+
>
10
+
<div class="flex gap-3 items-center mb-3">
11
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
12
+
{{ i "arrow-left" "w-5 h-5" }}
13
+
back
14
+
</a>
15
+
<span class="select-none before:content-['\00B7']"></span>
16
+
round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span>
17
+
<span class="select-none before:content-['\00B7']"></span>
18
+
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch">
19
+
view raw
20
+
</a>
21
+
</div>
22
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
23
+
{{ template "repo/pulls/fragments/pullHeader" . }}
24
+
</section>
25
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
26
+
</section>
77
27
{{ end }}
+81
-82
appview/pages/templates/repo/pulls/pull.html
+81
-82
appview/pages/templates/repo/pulls/pull.html
···
3
3
{{ end }}
4
4
5
5
{{ define "repoContent" }}
6
-
<header class="pb-4">
7
-
<h1 class="text-2xl dark:text-white">
8
-
{{ .Pull.Title }}
9
-
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
10
-
</h1>
11
-
</header>
12
-
13
-
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
14
-
{{ $icon := "ban" }}
15
-
16
-
{{ if .Pull.State.IsOpen }}
17
-
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
18
-
{{ $icon = "git-pull-request" }}
19
-
{{ else if .Pull.State.IsMerged }}
20
-
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
21
-
{{ $icon = "git-merge" }}
22
-
{{ end }}
23
-
24
-
<section class="mt-2">
25
-
<div class="flex items-center gap-2">
26
-
<div
27
-
id="state"
28
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"
29
-
>
30
-
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
-
<span class="text-white">{{ .Pull.State.String }}</span>
32
-
</div>
33
-
<span class="text-gray-500 dark:text-gray-400 text-sm">
34
-
opened by
35
-
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
36
-
<a href="/{{ $owner }}" class="no-underline hover:underline"
37
-
>{{ $owner }}</a
38
-
>
39
-
<span class="select-none before:content-['\00B7']"></span>
40
-
<time>{{ .Pull.Created | timeFmt }}</time>
41
-
<span class="select-none before:content-['\00B7']"></span>
42
-
<span>
43
-
targeting
44
-
<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">
45
-
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
46
-
</span>
47
-
</span>
48
-
{{ if not .Pull.IsPatchBased }}
49
-
<span>from
50
-
{{ if not .Pull.IsBranchBased }}
51
-
<a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a>
52
-
{{ end }}
53
-
54
-
{{ $fullRepo := .RepoInfo.FullName }}
55
-
{{ if not .Pull.IsBranchBased }}
56
-
{{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }}
57
-
{{ end }}
58
-
<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">
59
-
<a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
60
-
</span>
61
-
</span>
62
-
{{ end }}
63
-
</span>
64
-
</div>
65
-
66
-
{{ if .Pull.Body }}
67
-
<article id="body" class="mt-8 prose dark:prose-invert">
68
-
{{ .Pull.Body | markdown }}
69
-
</article>
70
-
{{ end }}
71
-
</section>
72
-
6
+
{{ template "repo/pulls/fragments/pullHeader" . }}
73
7
{{ end }}
74
8
75
9
{{ define "repoAfter" }}
···
88
22
{{ $targetBranch := .Pull.TargetBranch }}
89
23
{{ $repoName := .RepoInfo.FullName }}
90
24
{{ range $idx, $item := .Pull.Submissions }}
91
-
{{ $diff := $item.AsNiceDiff $targetBranch }}
92
25
{{ with $item }}
93
26
<details {{ if eq $idx $lastIdx }}open{{ end }}>
94
27
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
95
28
<div class="flex flex-wrap gap-2 items-center">
96
29
<!-- round number -->
97
30
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
98
-
#{{ .RoundNumber }}
31
+
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
99
32
</div>
100
33
<!-- round summary -->
101
34
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
···
117
50
{{ len .Comments }} comment{{$s}}
118
51
</span>
119
52
</div>
120
-
<!-- view patch -->
53
+
121
54
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
122
55
hx-boost="true"
123
56
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
124
57
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
125
58
</a>
59
+
{{ if not (eq .RoundNumber 0) }}
60
+
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
61
+
hx-boost="true"
62
+
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff">
63
+
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span>
64
+
</a>
65
+
<span id="interdiff-error-{{.RoundNumber}}"></span>
66
+
{{ end }}
126
67
</div>
127
68
</summary>
128
-
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
129
-
{{ range .Comments }}
130
-
<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">
69
+
70
+
{{ if .IsFormatPatch }}
71
+
{{ $patches := .AsFormatPatch }}
72
+
{{ $round := .RoundNumber }}
73
+
<details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm">
74
+
<summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
75
+
{{ $s := "s" }}
76
+
{{ if eq (len $patches) 1 }}
77
+
{{ $s = "" }}
78
+
{{ end }}
79
+
<div class="group-open:hidden flex items-center gap-2 ml-2">
80
+
{{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}}
81
+
</div>
82
+
<div class="hidden group-open:flex items-center gap-2 ml-2">
83
+
{{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}}
84
+
</div>
85
+
</summary>
86
+
{{ range $patches }}
87
+
<div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col">
88
+
<div class="flex items-center gap-2">
89
+
{{ i "git-commit-horizontal" "w-4 h-4" }}
90
+
<div class="text-sm text-gray-500 dark:text-gray-400">
91
+
{{ if not $.Pull.IsPatchBased }}
92
+
{{ $fullRepo := $.RepoInfo.FullName }}
93
+
{{ if $.Pull.IsForkBased }}
94
+
{{ if $.Pull.PullSource.Repo }}
95
+
{{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }}
96
+
<a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a>
97
+
{{ else }}
98
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
99
+
{{ end }}
100
+
{{ end }}
101
+
{{ else }}
102
+
<span class="font-mono">{{ slice .SHA 0 8 }}</span>
103
+
{{ end }}
104
+
</div>
105
+
<div class="flex items-center">
106
+
<span>{{ .Title }}</span>
107
+
{{ if gt (len .Body) 0 }}
108
+
<button
109
+
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"
110
+
hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')"
111
+
>
112
+
{{ i "ellipsis" "w-3 h-3" }}
113
+
</button>
114
+
{{ end }}
115
+
</div>
116
+
</div>
117
+
{{ if gt (len .Body) 0 }}
118
+
<p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2">
119
+
{{ nl2br .Body }}
120
+
</p>
121
+
{{ end }}
122
+
</div>
123
+
{{ end }}
124
+
</details>
125
+
{{ end }}
126
+
127
+
128
+
<div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative">
129
+
{{ range $cidx, $c := .Comments }}
130
+
<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">
131
+
{{ if gt $cidx 0 }}
131
132
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
133
+
{{ end }}
132
134
<div class="text-sm text-gray-500 dark:text-gray-400">
133
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
135
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
134
136
<a href="/{{$owner}}">{{$owner}}</a>
135
137
<span class="before:content-['ยท']"></span>
136
-
<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>
138
+
<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>
137
139
</div>
138
140
<div class="prose dark:prose-invert">
139
-
{{ .Body | markdown }}
141
+
{{ $c.Body | markdown }}
140
142
</div>
141
143
</div>
142
144
{{ end }}
···
164
166
{{ define "mergeStatus" }}
165
167
{{ if .Pull.State.IsClosed }}
166
168
<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">
167
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
168
169
<div class="flex items-center gap-2 text-black dark:text-white">
169
170
{{ i "ban" "w-4 h-4" }}
170
171
<span class="font-medium">closed without merging</span
···
173
174
</div>
174
175
{{ else if .Pull.State.IsMerged }}
175
176
<div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
176
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
177
177
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
178
178
{{ i "git-merge" "w-4 h-4" }}
179
179
<span class="font-medium">pull request successfully merged</span
···
182
182
</div>
183
183
{{ else if and .MergeCheck .MergeCheck.Error }}
184
184
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
185
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
186
185
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
187
186
{{ i "triangle-alert" "w-4 h-4" }}
188
187
<span class="font-medium">{{ .MergeCheck.Error }}</span>
···
190
189
</div>
191
190
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
192
191
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
193
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
194
192
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
195
193
<div class="flex items-center gap-2">
196
194
{{ i "triangle-alert" "w-4 h-4" }}
···
210
208
</div>
211
209
{{ else if .MergeCheck }}
212
210
<div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
213
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
214
211
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
215
212
{{ i "circle-check-big" "w-4 h-4" }}
216
213
<span class="font-medium">no conflicts, ready to merge</span>
···
222
219
{{ define "resubmitStatus" }}
223
220
{{ if .ResubmitCheck.Yes }}
224
221
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
225
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
226
222
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
227
223
{{ i "triangle-alert" "w-4 h-4" }}
228
224
<span class="font-medium">this branch has been updated, consider resubmitting</span>
···
230
226
</div>
231
227
{{ end }}
232
228
{{ end }}
229
+
230
+
{{ define "commits" }}
231
+
{{ end }}
+1
-1
appview/pages/templates/user/profile.html
+1
-1
appview/pages/templates/user/profile.html
···
179
179
180
180
181
181
{{ if gt $stats.Closed 0 }}
182
-
<span class="px-2 py-1/2 text-sm rounded text-black dark:text-white bg-gray-50 dark:bg-gray-700 ">
182
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
183
183
{{$stats.Closed}} closed
184
184
</span>
185
185
{{ end }}
+31
appview/pagination/page.go
+31
appview/pagination/page.go
···
1
+
package pagination
2
+
3
+
type Page struct {
4
+
Offset int // where to start from
5
+
Limit int // number of items in a page
6
+
}
7
+
8
+
func FirstPage() Page {
9
+
return Page{
10
+
Offset: 0,
11
+
Limit: 10,
12
+
}
13
+
}
14
+
15
+
func (p Page) Previous() Page {
16
+
if p.Offset-p.Limit < 0 {
17
+
return FirstPage()
18
+
} else {
19
+
return Page{
20
+
Offset: p.Offset - p.Limit,
21
+
Limit: p.Limit,
22
+
}
23
+
}
24
+
}
25
+
26
+
func (p Page) Next() Page {
27
+
return Page{
28
+
Offset: p.Offset + p.Limit,
29
+
Limit: p.Limit,
30
+
}
31
+
}
+451
appview/settings/settings.go
+451
appview/settings/settings.go
···
1
+
package settings
2
+
3
+
import (
4
+
"database/sql"
5
+
"errors"
6
+
"fmt"
7
+
"log"
8
+
"net/http"
9
+
"net/url"
10
+
"strings"
11
+
"time"
12
+
13
+
"github.com/go-chi/chi/v5"
14
+
"tangled.sh/tangled.sh/core/api/tangled"
15
+
"tangled.sh/tangled.sh/core/appview"
16
+
"tangled.sh/tangled.sh/core/appview/auth"
17
+
"tangled.sh/tangled.sh/core/appview/db"
18
+
"tangled.sh/tangled.sh/core/appview/email"
19
+
"tangled.sh/tangled.sh/core/appview/middleware"
20
+
"tangled.sh/tangled.sh/core/appview/pages"
21
+
22
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
23
+
lexutil "github.com/bluesky-social/indigo/lex/util"
24
+
"github.com/gliderlabs/ssh"
25
+
"github.com/google/uuid"
26
+
)
27
+
28
+
type Settings struct {
29
+
Db *db.DB
30
+
Auth *auth.Auth
31
+
Pages *pages.Pages
32
+
Config *appview.Config
33
+
}
34
+
35
+
func (s *Settings) Router() http.Handler {
36
+
r := chi.NewRouter()
37
+
38
+
r.Use(middleware.AuthMiddleware(s.Auth))
39
+
40
+
r.Get("/", s.settings)
41
+
42
+
r.Route("/keys", func(r chi.Router) {
43
+
r.Put("/", s.keys)
44
+
r.Delete("/", s.keys)
45
+
})
46
+
47
+
r.Route("/emails", func(r chi.Router) {
48
+
r.Put("/", s.emails)
49
+
r.Delete("/", s.emails)
50
+
r.Get("/verify", s.emailsVerify)
51
+
r.Post("/verify/resend", s.emailsVerifyResend)
52
+
r.Post("/primary", s.emailsPrimary)
53
+
})
54
+
55
+
return r
56
+
}
57
+
58
+
func (s *Settings) settings(w http.ResponseWriter, r *http.Request) {
59
+
user := s.Auth.GetUser(r)
60
+
pubKeys, err := db.GetPublicKeys(s.Db, user.Did)
61
+
if err != nil {
62
+
log.Println(err)
63
+
}
64
+
65
+
emails, err := db.GetAllEmails(s.Db, user.Did)
66
+
if err != nil {
67
+
log.Println(err)
68
+
}
69
+
70
+
s.Pages.Settings(w, pages.SettingsParams{
71
+
LoggedInUser: user,
72
+
PubKeys: pubKeys,
73
+
Emails: emails,
74
+
})
75
+
}
76
+
77
+
// buildVerificationEmail creates an email.Email struct for verification emails
78
+
func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
79
+
verifyURL := s.verifyUrl(did, emailAddr, code)
80
+
81
+
return email.Email{
82
+
APIKey: s.Config.ResendApiKey,
83
+
From: "noreply@notifs.tangled.sh",
84
+
To: emailAddr,
85
+
Subject: "Verify your Tangled email",
86
+
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
87
+
` + verifyURL,
88
+
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
89
+
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
90
+
}
91
+
}
92
+
93
+
// sendVerificationEmail handles the common logic for sending verification emails
94
+
func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
95
+
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
96
+
97
+
err := email.SendEmail(emailToSend)
98
+
if err != nil {
99
+
log.Printf("sending email: %s", err)
100
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
101
+
return err
102
+
}
103
+
104
+
return nil
105
+
}
106
+
107
+
func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
108
+
switch r.Method {
109
+
case http.MethodGet:
110
+
s.Pages.Notice(w, "settings-emails", "Unimplemented.")
111
+
log.Println("unimplemented")
112
+
return
113
+
case http.MethodPut:
114
+
did := s.Auth.GetDid(r)
115
+
emAddr := r.FormValue("email")
116
+
emAddr = strings.TrimSpace(emAddr)
117
+
118
+
if !email.IsValidEmail(emAddr) {
119
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
120
+
return
121
+
}
122
+
123
+
// check if email already exists in database
124
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
125
+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
126
+
log.Printf("checking for existing email: %s", err)
127
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
128
+
return
129
+
}
130
+
131
+
if err == nil {
132
+
if existingEmail.Verified {
133
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
134
+
return
135
+
}
136
+
137
+
s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
138
+
return
139
+
}
140
+
141
+
code := uuid.New().String()
142
+
143
+
// Begin transaction
144
+
tx, err := s.Db.Begin()
145
+
if err != nil {
146
+
log.Printf("failed to start transaction: %s", err)
147
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
148
+
return
149
+
}
150
+
defer tx.Rollback()
151
+
152
+
if err := db.AddEmail(tx, db.Email{
153
+
Did: did,
154
+
Address: emAddr,
155
+
Verified: false,
156
+
VerificationCode: code,
157
+
}); err != nil {
158
+
log.Printf("adding email: %s", err)
159
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
160
+
return
161
+
}
162
+
163
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
164
+
return
165
+
}
166
+
167
+
// Commit transaction
168
+
if err := tx.Commit(); err != nil {
169
+
log.Printf("failed to commit transaction: %s", err)
170
+
s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
171
+
return
172
+
}
173
+
174
+
s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
175
+
return
176
+
case http.MethodDelete:
177
+
did := s.Auth.GetDid(r)
178
+
emailAddr := r.FormValue("email")
179
+
emailAddr = strings.TrimSpace(emailAddr)
180
+
181
+
// Begin transaction
182
+
tx, err := s.Db.Begin()
183
+
if err != nil {
184
+
log.Printf("failed to start transaction: %s", err)
185
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
186
+
return
187
+
}
188
+
defer tx.Rollback()
189
+
190
+
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
191
+
log.Printf("deleting email: %s", err)
192
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
193
+
return
194
+
}
195
+
196
+
// Commit transaction
197
+
if err := tx.Commit(); err != nil {
198
+
log.Printf("failed to commit transaction: %s", err)
199
+
s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
200
+
return
201
+
}
202
+
203
+
s.Pages.HxLocation(w, "/settings")
204
+
return
205
+
}
206
+
}
207
+
208
+
func (s *Settings) verifyUrl(did string, email string, code string) string {
209
+
var appUrl string
210
+
if s.Config.Dev {
211
+
appUrl = "http://" + s.Config.ListenAddr
212
+
} else {
213
+
appUrl = "https://tangled.sh"
214
+
}
215
+
216
+
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
217
+
}
218
+
219
+
func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
220
+
q := r.URL.Query()
221
+
222
+
// Get the parameters directly from the query
223
+
emailAddr := q.Get("email")
224
+
did := q.Get("did")
225
+
code := q.Get("code")
226
+
227
+
valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
228
+
if err != nil {
229
+
log.Printf("checking email verification: %s", err)
230
+
s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
231
+
return
232
+
}
233
+
234
+
if !valid {
235
+
s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
236
+
return
237
+
}
238
+
239
+
// Mark email as verified in the database
240
+
if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
241
+
log.Printf("marking email as verified: %s", err)
242
+
s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
243
+
return
244
+
}
245
+
246
+
http.Redirect(w, r, "/settings", http.StatusSeeOther)
247
+
}
248
+
249
+
func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
250
+
if r.Method != http.MethodPost {
251
+
s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
252
+
return
253
+
}
254
+
255
+
did := s.Auth.GetDid(r)
256
+
emAddr := r.FormValue("email")
257
+
emAddr = strings.TrimSpace(emAddr)
258
+
259
+
if !email.IsValidEmail(emAddr) {
260
+
s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
261
+
return
262
+
}
263
+
264
+
// Check if email exists and is unverified
265
+
existingEmail, err := db.GetEmail(s.Db, did, emAddr)
266
+
if err != nil {
267
+
if errors.Is(err, sql.ErrNoRows) {
268
+
s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
269
+
} else {
270
+
log.Printf("checking for existing email: %s", err)
271
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
272
+
}
273
+
return
274
+
}
275
+
276
+
if existingEmail.Verified {
277
+
s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
278
+
return
279
+
}
280
+
281
+
// Check if last verification email was sent less than 10 minutes ago
282
+
if existingEmail.LastSent != nil {
283
+
timeSinceLastSent := time.Since(*existingEmail.LastSent)
284
+
if timeSinceLastSent < 10*time.Minute {
285
+
waitTime := 10*time.Minute - timeSinceLastSent
286
+
s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
287
+
return
288
+
}
289
+
}
290
+
291
+
// Generate new verification code
292
+
code := uuid.New().String()
293
+
294
+
// Begin transaction
295
+
tx, err := s.Db.Begin()
296
+
if err != nil {
297
+
log.Printf("failed to start transaction: %s", err)
298
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
299
+
return
300
+
}
301
+
defer tx.Rollback()
302
+
303
+
// Update the verification code and last sent time
304
+
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
305
+
log.Printf("updating email verification: %s", err)
306
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
307
+
return
308
+
}
309
+
310
+
// Send verification email
311
+
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
312
+
return
313
+
}
314
+
315
+
// Commit transaction
316
+
if err := tx.Commit(); err != nil {
317
+
log.Printf("failed to commit transaction: %s", err)
318
+
s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
319
+
return
320
+
}
321
+
322
+
s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
323
+
}
324
+
325
+
func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
326
+
did := s.Auth.GetDid(r)
327
+
emailAddr := r.FormValue("email")
328
+
emailAddr = strings.TrimSpace(emailAddr)
329
+
330
+
if emailAddr == "" {
331
+
s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
332
+
return
333
+
}
334
+
335
+
if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
336
+
log.Printf("setting primary email: %s", err)
337
+
s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
338
+
return
339
+
}
340
+
341
+
s.Pages.HxLocation(w, "/settings")
342
+
}
343
+
344
+
func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
345
+
switch r.Method {
346
+
case http.MethodGet:
347
+
s.Pages.Notice(w, "settings-keys", "Unimplemented.")
348
+
log.Println("unimplemented")
349
+
return
350
+
case http.MethodPut:
351
+
did := s.Auth.GetDid(r)
352
+
key := r.FormValue("key")
353
+
key = strings.TrimSpace(key)
354
+
name := r.FormValue("name")
355
+
client, _ := s.Auth.AuthorizedClient(r)
356
+
357
+
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
358
+
if err != nil {
359
+
log.Printf("parsing public key: %s", err)
360
+
s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
361
+
return
362
+
}
363
+
364
+
rkey := appview.TID()
365
+
366
+
tx, err := s.Db.Begin()
367
+
if err != nil {
368
+
log.Printf("failed to start tx; adding public key: %s", err)
369
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
370
+
return
371
+
}
372
+
defer tx.Rollback()
373
+
374
+
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
375
+
log.Printf("adding public key: %s", err)
376
+
s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
377
+
return
378
+
}
379
+
380
+
// store in pds too
381
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
382
+
Collection: tangled.PublicKeyNSID,
383
+
Repo: did,
384
+
Rkey: rkey,
385
+
Record: &lexutil.LexiconTypeDecoder{
386
+
Val: &tangled.PublicKey{
387
+
Created: time.Now().Format(time.RFC3339),
388
+
Key: key,
389
+
Name: name,
390
+
}},
391
+
})
392
+
// invalid record
393
+
if err != nil {
394
+
log.Printf("failed to create record: %s", err)
395
+
s.Pages.Notice(w, "settings-keys", "Failed to create record.")
396
+
return
397
+
}
398
+
399
+
log.Println("created atproto record: ", resp.Uri)
400
+
401
+
err = tx.Commit()
402
+
if err != nil {
403
+
log.Printf("failed to commit tx; adding public key: %s", err)
404
+
s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
405
+
return
406
+
}
407
+
408
+
s.Pages.HxLocation(w, "/settings")
409
+
return
410
+
411
+
case http.MethodDelete:
412
+
did := s.Auth.GetDid(r)
413
+
q := r.URL.Query()
414
+
415
+
name := q.Get("name")
416
+
rkey := q.Get("rkey")
417
+
key := q.Get("key")
418
+
419
+
log.Println(name)
420
+
log.Println(rkey)
421
+
log.Println(key)
422
+
423
+
client, _ := s.Auth.AuthorizedClient(r)
424
+
425
+
if err := db.RemovePublicKey(s.Db, did, name, key); err != nil {
426
+
log.Printf("removing public key: %s", err)
427
+
s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
428
+
return
429
+
}
430
+
431
+
if rkey != "" {
432
+
// remove from pds too
433
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
434
+
Collection: tangled.PublicKeyNSID,
435
+
Repo: did,
436
+
Rkey: rkey,
437
+
})
438
+
439
+
// invalid record
440
+
if err != nil {
441
+
log.Printf("failed to delete record from PDS: %s", err)
442
+
s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
443
+
return
444
+
}
445
+
}
446
+
log.Println("deleted successfully")
447
+
448
+
s.Pages.HxLocation(w, "/settings")
449
+
return
450
+
}
451
+
}
+2
-1
appview/state/follow.go
+2
-1
appview/state/follow.go
···
8
8
comatproto "github.com/bluesky-social/indigo/api/atproto"
9
9
lexutil "github.com/bluesky-social/indigo/lex/util"
10
10
tangled "tangled.sh/tangled.sh/core/api/tangled"
11
+
"tangled.sh/tangled.sh/core/appview"
11
12
"tangled.sh/tangled.sh/core/appview/db"
12
13
"tangled.sh/tangled.sh/core/appview/pages"
13
14
)
···
36
37
switch r.Method {
37
38
case http.MethodPost:
38
39
createdAt := time.Now().Format(time.RFC3339)
39
-
rkey := s.TID()
40
+
rkey := appview.TID()
40
41
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
41
42
Collection: tangled.GraphFollowNSID,
42
43
Repo: currentUser.Did,
+7
-92
appview/state/middleware.go
+7
-92
appview/state/middleware.go
···
10
10
11
11
"slices"
12
12
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
13
"github.com/bluesky-social/indigo/atproto/identity"
15
-
"github.com/bluesky-social/indigo/xrpc"
16
14
"github.com/go-chi/chi/v5"
17
-
"tangled.sh/tangled.sh/core/appview"
18
-
"tangled.sh/tangled.sh/core/appview/auth"
19
15
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
20
17
)
21
18
22
-
type Middleware func(http.Handler) http.Handler
23
-
24
-
func AuthMiddleware(s *State) Middleware {
25
-
return func(next http.Handler) http.Handler {
26
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
28
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
29
-
}
30
-
if r.Header.Get("HX-Request") == "true" {
31
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
32
-
w.Header().Set("HX-Redirect", "/login")
33
-
w.WriteHeader(http.StatusOK)
34
-
}
35
-
}
36
-
37
-
session, err := s.auth.GetSession(r)
38
-
if session.IsNew || err != nil {
39
-
log.Printf("not logged in, redirecting")
40
-
redirectFunc(w, r)
41
-
return
42
-
}
43
-
44
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
45
-
if !ok || !authorized {
46
-
log.Printf("not logged in, redirecting")
47
-
redirectFunc(w, r)
48
-
return
49
-
}
50
-
51
-
// refresh if nearing expiry
52
-
// TODO: dedup with /login
53
-
expiryStr := session.Values[appview.SessionExpiry].(string)
54
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
55
-
if err != nil {
56
-
log.Println("invalid expiry time", err)
57
-
redirectFunc(w, r)
58
-
return
59
-
}
60
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
61
-
did, ok2 := session.Values[appview.SessionDid].(string)
62
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
63
-
64
-
if !ok1 || !ok2 || !ok3 {
65
-
log.Println("invalid expiry time", err)
66
-
redirectFunc(w, r)
67
-
return
68
-
}
69
-
70
-
if time.Now().After(expiry) {
71
-
log.Println("token expired, refreshing ...")
72
-
73
-
client := xrpc.Client{
74
-
Host: pdsUrl,
75
-
Auth: &xrpc.AuthInfo{
76
-
Did: did,
77
-
AccessJwt: refreshJwt,
78
-
RefreshJwt: refreshJwt,
79
-
},
80
-
}
81
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
82
-
if err != nil {
83
-
log.Println("failed to refresh session", err)
84
-
redirectFunc(w, r)
85
-
return
86
-
}
87
-
88
-
sessionish := auth.RefreshSessionWrapper{atSession}
89
-
90
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
91
-
if err != nil {
92
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
93
-
return
94
-
}
95
-
96
-
log.Println("successfully refreshed token")
97
-
}
98
-
99
-
next.ServeHTTP(w, r)
100
-
})
101
-
}
102
-
}
103
-
104
-
func knotRoleMiddleware(s *State, group string) Middleware {
19
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
105
20
return func(next http.Handler) http.Handler {
106
21
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107
22
// requires auth also
···
131
46
}
132
47
}
133
48
134
-
func KnotOwner(s *State) Middleware {
49
+
func KnotOwner(s *State) middleware.Middleware {
135
50
return knotRoleMiddleware(s, "server:owner")
136
51
}
137
52
138
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
53
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
139
54
return func(next http.Handler) http.Handler {
140
55
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
141
56
// requires auth also
···
175
90
})
176
91
}
177
92
178
-
func ResolveIdent(s *State) Middleware {
93
+
func ResolveIdent(s *State) middleware.Middleware {
179
94
excluded := []string{"favicon.ico"}
180
95
181
96
return func(next http.Handler) http.Handler {
···
201
116
}
202
117
}
203
118
204
-
func ResolveRepo(s *State) Middleware {
119
+
func ResolveRepo(s *State) middleware.Middleware {
205
120
return func(next http.Handler) http.Handler {
206
121
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
207
122
repoName := chi.URLParam(req, "repo")
···
230
145
}
231
146
232
147
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
233
-
func ResolvePull(s *State) Middleware {
148
+
func ResolvePull(s *State) middleware.Middleware {
234
149
return func(next http.Handler) http.Handler {
235
150
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236
151
f, err := fullyResolvedRepo(r)
+183
-80
appview/state/pull.go
+183
-80
appview/state/pull.go
···
10
10
"net/http"
11
11
"net/url"
12
12
"strconv"
13
-
"strings"
14
13
"time"
15
14
16
-
"github.com/go-chi/chi/v5"
17
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/appview"
18
17
"tangled.sh/tangled.sh/core/appview/auth"
19
18
"tangled.sh/tangled.sh/core/appview/db"
20
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/patchutil"
21
21
"tangled.sh/tangled.sh/core/types"
22
22
23
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
24
"github.com/bluesky-social/indigo/atproto/syntax"
25
25
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
26
27
)
27
28
28
29
// htmx fragment
···
120
121
resubmitResult = s.resubmitCheck(f, pull)
121
122
}
122
123
123
-
var pullSourceRepo *db.Repo
124
-
if pull.PullSource != nil {
125
-
if pull.PullSource.RepoAt != nil {
126
-
pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
127
-
if err != nil {
128
-
log.Printf("failed to get repo by at uri: %v", err)
129
-
return
130
-
}
131
-
}
132
-
}
133
-
134
124
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
135
-
LoggedInUser: user,
136
-
RepoInfo: f.RepoInfo(s, user),
137
-
DidHandleMap: didHandleMap,
138
-
Pull: pull,
139
-
PullSourceRepo: pullSourceRepo,
140
-
MergeCheck: mergeCheckResponse,
141
-
ResubmitCheck: resubmitResult,
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(s, user),
127
+
DidHandleMap: didHandleMap,
128
+
Pull: pull,
129
+
MergeCheck: mergeCheckResponse,
130
+
ResubmitCheck: resubmitResult,
142
131
})
143
132
}
144
133
···
254
243
255
244
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
256
245
if latestSubmission.SourceRev != result.Branch.Hash {
246
+
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
257
247
return pages.ShouldResubmit
258
248
}
259
249
···
294
284
}
295
285
}
296
286
287
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
288
+
297
289
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
298
290
LoggedInUser: user,
299
291
DidHandleMap: didHandleMap,
···
301
293
Pull: pull,
302
294
Round: roundIdInt,
303
295
Submission: pull.Submissions[roundIdInt],
304
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
296
+
Diff: &diff,
305
297
})
306
298
307
299
}
308
300
301
+
func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
302
+
user := s.auth.GetUser(r)
303
+
304
+
f, err := fullyResolvedRepo(r)
305
+
if err != nil {
306
+
log.Println("failed to get repo and knot", err)
307
+
return
308
+
}
309
+
310
+
pull, ok := r.Context().Value("pull").(*db.Pull)
311
+
if !ok {
312
+
log.Println("failed to get pull")
313
+
s.pages.Notice(w, "pull-error", "Failed to get pull.")
314
+
return
315
+
}
316
+
317
+
roundId := chi.URLParam(r, "round")
318
+
roundIdInt, err := strconv.Atoi(roundId)
319
+
if err != nil || roundIdInt >= len(pull.Submissions) {
320
+
http.Error(w, "bad round id", http.StatusBadRequest)
321
+
log.Println("failed to parse round id", err)
322
+
return
323
+
}
324
+
325
+
if roundIdInt == 0 {
326
+
http.Error(w, "bad round id", http.StatusBadRequest)
327
+
log.Println("cannot interdiff initial submission")
328
+
return
329
+
}
330
+
331
+
identsToResolve := []string{pull.OwnerDid}
332
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
333
+
didHandleMap := make(map[string]string)
334
+
for _, identity := range resolvedIds {
335
+
if !identity.Handle.IsInvalidHandle() {
336
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
337
+
} else {
338
+
didHandleMap[identity.DID.String()] = identity.DID.String()
339
+
}
340
+
}
341
+
342
+
currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
343
+
if err != nil {
344
+
log.Println("failed to interdiff; current patch malformed")
345
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
346
+
return
347
+
}
348
+
349
+
previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
350
+
if err != nil {
351
+
log.Println("failed to interdiff; previous patch malformed")
352
+
s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
353
+
return
354
+
}
355
+
356
+
interdiff := patchutil.Interdiff(previousPatch, currentPatch)
357
+
358
+
s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
359
+
LoggedInUser: s.auth.GetUser(r),
360
+
RepoInfo: f.RepoInfo(s, user),
361
+
Pull: pull,
362
+
Round: roundIdInt,
363
+
DidHandleMap: didHandleMap,
364
+
Interdiff: interdiff,
365
+
})
366
+
return
367
+
}
368
+
309
369
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
310
370
pull, ok := r.Context().Value("pull").(*db.Pull)
311
371
if !ok {
···
464
524
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
465
525
Collection: tangled.RepoPullCommentNSID,
466
526
Repo: user.Did,
467
-
Rkey: s.TID(),
527
+
Rkey: appview.TID(),
468
528
Record: &lexutil.LexiconTypeDecoder{
469
529
Val: &tangled.RepoPullComment{
470
530
Repo: &atUri,
···
557
617
sourceBranch := r.FormValue("sourceBranch")
558
618
patch := r.FormValue("patch")
559
619
560
-
// Validate required fields for all PR types
561
-
if title == "" || body == "" || targetBranch == "" {
562
-
s.pages.Notice(w, "pull", "Title, body and target branch are required.")
563
-
return
564
-
}
565
-
566
-
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
567
-
if err != nil {
568
-
log.Println("failed to create unsigned client to %s: %v", f.Knot, err)
569
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
570
-
return
571
-
}
572
-
573
-
caps, err := us.Capabilities()
574
-
if err != nil {
575
-
log.Println("error fetching knot caps", f.Knot, err)
576
-
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
620
+
if targetBranch == "" {
621
+
s.pages.Notice(w, "pull", "Target branch is required.")
577
622
return
578
623
}
579
624
···
583
628
isForkBased := fromFork != "" && sourceBranch != ""
584
629
isPatchBased := patch != "" && !isBranchBased && !isForkBased
585
630
631
+
if isPatchBased && !patchutil.IsFormatPatch(patch) {
632
+
if title == "" {
633
+
s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
634
+
return
635
+
}
636
+
}
637
+
586
638
// Validate we have at least one valid PR creation method
587
639
if !isBranchBased && !isPatchBased && !isForkBased {
588
640
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
···
595
647
return
596
648
}
597
649
650
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
651
+
if err != nil {
652
+
log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
653
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
654
+
return
655
+
}
656
+
657
+
caps, err := us.Capabilities()
658
+
if err != nil {
659
+
log.Println("error fetching knot caps", f.Knot, err)
660
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
661
+
return
662
+
}
663
+
664
+
if !caps.PullRequests.FormatPatch {
665
+
s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
666
+
return
667
+
}
668
+
598
669
// Handle the PR creation based on the type
599
670
if isBranchBased {
600
671
if !caps.PullRequests.BranchSubmissions {
···
635
706
return
636
707
}
637
708
638
-
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
709
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
639
710
if err != nil {
640
711
log.Println("failed to compare", err)
641
712
s.pages.Notice(w, "pull", err.Error())
642
713
return
643
714
}
644
715
645
-
sourceRev := diffTreeResponse.DiffTree.Rev2
646
-
patch := diffTreeResponse.DiffTree.Patch
716
+
sourceRev := comparison.Rev2
717
+
patch := comparison.Patch
647
718
648
-
if !isPatchValid(patch) {
719
+
if !patchutil.IsPatchValid(patch) {
649
720
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
650
721
return
651
722
}
···
654
725
}
655
726
656
727
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
657
-
if !isPatchValid(patch) {
728
+
if !patchutil.IsPatchValid(patch) {
658
729
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
659
730
return
660
731
}
···
714
785
// hiddenRef: hidden/feature-1/main (on repo-fork)
715
786
// targetBranch: main (on repo-1)
716
787
// sourceBranch: feature-1 (on repo-fork)
717
-
diffTreeResponse, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
788
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
718
789
if err != nil {
719
790
log.Println("failed to compare across branches", err)
720
791
s.pages.Notice(w, "pull", err.Error())
721
792
return
722
793
}
723
794
724
-
sourceRev := diffTreeResponse.DiffTree.Rev2
725
-
patch := diffTreeResponse.DiffTree.Patch
795
+
sourceRev := comparison.Rev2
796
+
patch := comparison.Patch
726
797
727
-
if !isPatchValid(patch) {
798
+
if !patchutil.IsPatchValid(patch) {
728
799
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
729
800
return
730
801
}
···
742
813
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
743
814
}
744
815
745
-
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) {
816
+
func (s *State) createPullRequest(
817
+
w http.ResponseWriter,
818
+
r *http.Request,
819
+
f *FullyResolvedRepo,
820
+
user *auth.User,
821
+
title, body, targetBranch string,
822
+
patch string,
823
+
sourceRev string,
824
+
pullSource *db.PullSource,
825
+
recordPullSource *tangled.RepoPull_Source,
826
+
) {
746
827
tx, err := s.db.BeginTx(r.Context(), nil)
747
828
if err != nil {
748
829
log.Println("failed to start tx")
···
751
832
}
752
833
defer tx.Rollback()
753
834
754
-
rkey := s.TID()
835
+
// We've already checked earlier if it's diff-based and title is empty,
836
+
// so if it's still empty now, it's intentionally skipped owing to format-patch.
837
+
if title == "" {
838
+
formatPatches, err := patchutil.ExtractPatches(patch)
839
+
if err != nil {
840
+
s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
841
+
return
842
+
}
843
+
if len(formatPatches) == 0 {
844
+
s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
845
+
return
846
+
}
847
+
848
+
title = formatPatches[0].Title
849
+
body = formatPatches[0].Body
850
+
}
851
+
852
+
rkey := appview.TID()
755
853
initialSubmission := db.PullSubmission{
756
854
Patch: patch,
757
855
SourceRev: sourceRev,
···
807
905
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
808
906
}
809
907
908
+
func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
909
+
_, err := fullyResolvedRepo(r)
910
+
if err != nil {
911
+
log.Println("failed to get repo and knot", err)
912
+
return
913
+
}
914
+
915
+
patch := r.FormValue("patch")
916
+
if patch == "" {
917
+
s.pages.Notice(w, "patch-error", "Patch is required.")
918
+
return
919
+
}
920
+
921
+
if patch == "" || !patchutil.IsPatchValid(patch) {
922
+
s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
923
+
return
924
+
}
925
+
926
+
if patchutil.IsFormatPatch(patch) {
927
+
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.")
928
+
} else {
929
+
s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
930
+
}
931
+
}
932
+
810
933
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
811
934
user := s.auth.GetUser(r)
812
935
f, err := fullyResolvedRepo(r)
···
1021
1144
1022
1145
if err = validateResubmittedPatch(pull, patch); err != nil {
1023
1146
s.pages.Notice(w, "resubmit-error", err.Error())
1147
+
return
1024
1148
}
1025
1149
1026
1150
tx, err := s.db.BeginTx(r.Context(), nil)
···
1112
1236
return
1113
1237
}
1114
1238
1115
-
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1239
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1116
1240
if err != nil {
1117
1241
log.Printf("compare request failed: %s", err)
1118
1242
s.pages.Notice(w, "resubmit-error", err.Error())
1119
1243
return
1120
1244
}
1121
1245
1122
-
sourceRev := diffTreeResponse.DiffTree.Rev2
1123
-
patch := diffTreeResponse.DiffTree.Patch
1246
+
sourceRev := comparison.Rev2
1247
+
patch := comparison.Patch
1124
1248
1125
1249
if err = validateResubmittedPatch(pull, patch); err != nil {
1126
1250
s.pages.Notice(w, "resubmit-error", err.Error())
1251
+
return
1127
1252
}
1128
1253
1129
1254
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
···
1249
1374
}
1250
1375
1251
1376
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1252
-
diffTreeResponse, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1377
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1253
1378
if err != nil {
1254
1379
log.Printf("failed to compare branches: %s", err)
1255
1380
s.pages.Notice(w, "resubmit-error", err.Error())
1256
1381
return
1257
1382
}
1258
1383
1259
-
sourceRev := diffTreeResponse.DiffTree.Rev2
1260
-
patch := diffTreeResponse.DiffTree.Patch
1384
+
sourceRev := comparison.Rev2
1385
+
patch := comparison.Patch
1261
1386
1262
1387
if err = validateResubmittedPatch(pull, patch); err != nil {
1263
1388
s.pages.Notice(w, "resubmit-error", err.Error())
1389
+
return
1264
1390
}
1265
1391
1266
1392
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
···
1338
1464
return fmt.Errorf("Patch is identical to previous submission.")
1339
1465
}
1340
1466
1341
-
if !isPatchValid(patch) {
1467
+
if !patchutil.IsPatchValid(patch) {
1342
1468
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1343
1469
}
1344
1470
···
1516
1642
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1517
1643
return
1518
1644
}
1519
-
1520
-
// Very basic validation to check if it looks like a diff/patch
1521
-
// A valid patch usually starts with diff or --- lines
1522
-
func isPatchValid(patch string) bool {
1523
-
// Basic validation to check if it looks like a diff/patch
1524
-
// A valid patch usually starts with diff or --- lines
1525
-
if len(patch) == 0 {
1526
-
return false
1527
-
}
1528
-
1529
-
lines := strings.Split(patch, "\n")
1530
-
if len(lines) < 2 {
1531
-
return false
1532
-
}
1533
-
1534
-
// Check for common patch format markers
1535
-
firstLine := strings.TrimSpace(lines[0])
1536
-
return strings.HasPrefix(firstLine, "diff ") ||
1537
-
strings.HasPrefix(firstLine, "--- ") ||
1538
-
strings.HasPrefix(firstLine, "Index: ") ||
1539
-
strings.HasPrefix(firstLine, "+++ ") ||
1540
-
strings.HasPrefix(firstLine, "@@ ")
1541
-
}
+14
-5
appview/state/repo.go
+14
-5
appview/state/repo.go
···
23
23
"github.com/go-chi/chi/v5"
24
24
"github.com/go-git/go-git/v5/plumbing"
25
25
"tangled.sh/tangled.sh/core/api/tangled"
26
+
"tangled.sh/tangled.sh/core/appview"
26
27
"tangled.sh/tangled.sh/core/appview/auth"
27
28
"tangled.sh/tangled.sh/core/appview/db"
28
29
"tangled.sh/tangled.sh/core/appview/pages"
29
30
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
+
"tangled.sh/tangled.sh/core/appview/pagination"
30
32
"tangled.sh/tangled.sh/core/types"
31
33
32
34
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
1116
1118
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1117
1119
Collection: tangled.RepoIssueStateNSID,
1118
1120
Repo: user.Did,
1119
-
Rkey: s.TID(),
1121
+
Rkey: appview.TID(),
1120
1122
Record: &lexutil.LexiconTypeDecoder{
1121
1123
Val: &tangled.RepoIssueState{
1122
1124
Issue: issue.IssueAt,
···
1220
1222
}
1221
1223
1222
1224
commentId := mathrand.IntN(1000000)
1223
-
rkey := s.TID()
1225
+
rkey := appview.TID()
1224
1226
1225
1227
err := db.NewIssueComment(s.db, &db.Comment{
1226
1228
OwnerDid: user.Did,
···
1558
1560
isOpen = true
1559
1561
}
1560
1562
1563
+
page, ok := r.Context().Value("page").(pagination.Page)
1564
+
if !ok {
1565
+
log.Println("failed to get page")
1566
+
page = pagination.FirstPage()
1567
+
}
1568
+
1561
1569
user := s.auth.GetUser(r)
1562
1570
f, err := fullyResolvedRepo(r)
1563
1571
if err != nil {
···
1565
1573
return
1566
1574
}
1567
1575
1568
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1576
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1569
1577
if err != nil {
1570
1578
log.Println("failed to get issues", err)
1571
1579
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
1592
1600
Issues: issues,
1593
1601
DidHandleMap: didHandleMap,
1594
1602
FilteringByOpen: isOpen,
1603
+
Page: page,
1595
1604
})
1596
1605
return
1597
1606
}
···
1650
1659
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1651
1660
Collection: tangled.RepoIssueNSID,
1652
1661
Repo: user.Did,
1653
-
Rkey: s.TID(),
1662
+
Rkey: appview.TID(),
1654
1663
Record: &lexutil.LexiconTypeDecoder{
1655
1664
Val: &tangled.RepoIssue{
1656
1665
Repo: atUri,
···
1754
1763
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1755
1764
sourceAt := f.RepoAt.String()
1756
1765
1757
-
rkey := s.TID()
1766
+
rkey := appview.TID()
1758
1767
repo := &db.Repo{
1759
1768
Did: user.Did,
1760
1769
Name: forkName,
+28
-23
appview/state/router.go
+28
-23
appview/state/router.go
···
5
5
"strings"
6
6
7
7
"github.com/go-chi/chi/v5"
8
+
"tangled.sh/tangled.sh/core/appview/middleware"
9
+
"tangled.sh/tangled.sh/core/appview/settings"
8
10
"tangled.sh/tangled.sh/core/appview/state/userutil"
9
11
)
10
12
···
66
68
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
67
69
68
70
r.Route("/issues", func(r chi.Router) {
69
-
r.Get("/", s.RepoIssues)
71
+
r.With(middleware.Paginate).Get("/", s.RepoIssues)
70
72
r.Get("/{issue}", s.RepoSingleIssue)
71
73
72
74
r.Group(func(r chi.Router) {
73
-
r.Use(AuthMiddleware(s))
75
+
r.Use(middleware.AuthMiddleware(s.auth))
74
76
r.Get("/new", s.NewIssue)
75
77
r.Post("/new", s.NewIssue)
76
78
r.Post("/{issue}/comment", s.NewIssueComment)
···
86
88
})
87
89
88
90
r.Route("/fork", func(r chi.Router) {
89
-
r.Use(AuthMiddleware(s))
91
+
r.Use(middleware.AuthMiddleware(s.auth))
90
92
r.Get("/", s.ForkRepo)
91
93
r.Post("/", s.ForkRepo)
92
94
})
93
95
94
96
r.Route("/pulls", func(r chi.Router) {
95
97
r.Get("/", s.RepoPulls)
96
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
98
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
97
99
r.Get("/", s.NewPull)
98
100
r.Get("/patch-upload", s.PatchUploadFragment)
101
+
r.Post("/validate-patch", s.ValidatePatch)
99
102
r.Get("/compare-branches", s.CompareBranchesFragment)
100
103
r.Get("/compare-forks", s.CompareForksFragment)
101
104
r.Get("/fork-branches", s.CompareForksBranchesFragment)
···
108
111
109
112
r.Route("/round/{round}", func(r chi.Router) {
110
113
r.Get("/", s.RepoPullPatch)
114
+
r.Get("/interdiff", s.RepoPullInterdiff)
111
115
r.Get("/actions", s.PullActions)
112
-
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
116
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
113
117
r.Get("/", s.PullComment)
114
118
r.Post("/", s.PullComment)
115
119
})
···
120
124
})
121
125
122
126
r.Group(func(r chi.Router) {
123
-
r.Use(AuthMiddleware(s))
127
+
r.Use(middleware.AuthMiddleware(s.auth))
124
128
r.Route("/resubmit", func(r chi.Router) {
125
129
r.Get("/", s.ResubmitPull)
126
130
r.Post("/", s.ResubmitPull)
···
143
147
144
148
// settings routes, needs auth
145
149
r.Group(func(r chi.Router) {
146
-
r.Use(AuthMiddleware(s))
150
+
r.Use(middleware.AuthMiddleware(s.auth))
147
151
// repo description can only be edited by owner
148
152
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
149
153
r.Put("/", s.RepoDescription)
···
174
178
175
179
r.Get("/", s.Timeline)
176
180
177
-
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
181
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
178
182
179
183
r.Route("/login", func(r chi.Router) {
180
184
r.Get("/", s.Login)
···
182
186
})
183
187
184
188
r.Route("/knots", func(r chi.Router) {
185
-
r.Use(AuthMiddleware(s))
189
+
r.Use(middleware.AuthMiddleware(s.auth))
186
190
r.Get("/", s.Knots)
187
191
r.Post("/key", s.RegistrationKey)
188
192
···
200
204
201
205
r.Route("/repo", func(r chi.Router) {
202
206
r.Route("/new", func(r chi.Router) {
203
-
r.Use(AuthMiddleware(s))
207
+
r.Use(middleware.AuthMiddleware(s.auth))
204
208
r.Get("/", s.NewRepo)
205
209
r.Post("/", s.NewRepo)
206
210
})
207
211
// r.Post("/import", s.ImportRepo)
208
212
})
209
213
210
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
214
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
211
215
r.Post("/", s.Follow)
212
216
r.Delete("/", s.Follow)
213
217
})
214
218
215
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
219
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
216
220
r.Post("/", s.Star)
217
221
r.Delete("/", s.Star)
218
222
})
219
223
220
-
r.Route("/settings", func(r chi.Router) {
221
-
r.Use(AuthMiddleware(s))
222
-
r.Get("/", s.Settings)
223
-
r.Put("/keys", s.SettingsKeys)
224
-
r.Delete("/keys", s.SettingsKeys)
225
-
r.Put("/emails", s.SettingsEmails)
226
-
r.Delete("/emails", s.SettingsEmails)
227
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
228
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
229
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
230
-
})
224
+
r.Mount("/settings", s.SettingsRouter())
231
225
232
226
r.Get("/keys/{user}", s.Keys)
233
227
···
236
230
})
237
231
return r
238
232
}
233
+
234
+
func (s *State) SettingsRouter() http.Handler {
235
+
settings := &settings.Settings{
236
+
Db: s.db,
237
+
Auth: s.auth,
238
+
Pages: s.pages,
239
+
Config: s.config,
240
+
}
241
+
242
+
return settings.Router()
243
+
}
-416
appview/state/settings.go
-416
appview/state/settings.go
···
1
-
package state
2
-
3
-
import (
4
-
"database/sql"
5
-
"errors"
6
-
"fmt"
7
-
"log"
8
-
"net/http"
9
-
"net/url"
10
-
"strings"
11
-
"time"
12
-
13
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
14
-
lexutil "github.com/bluesky-social/indigo/lex/util"
15
-
"github.com/gliderlabs/ssh"
16
-
"github.com/google/uuid"
17
-
"tangled.sh/tangled.sh/core/api/tangled"
18
-
"tangled.sh/tangled.sh/core/appview/db"
19
-
"tangled.sh/tangled.sh/core/appview/email"
20
-
"tangled.sh/tangled.sh/core/appview/pages"
21
-
)
22
-
23
-
func (s *State) Settings(w http.ResponseWriter, r *http.Request) {
24
-
user := s.auth.GetUser(r)
25
-
pubKeys, err := db.GetPublicKeys(s.db, user.Did)
26
-
if err != nil {
27
-
log.Println(err)
28
-
}
29
-
30
-
emails, err := db.GetAllEmails(s.db, user.Did)
31
-
if err != nil {
32
-
log.Println(err)
33
-
}
34
-
35
-
s.pages.Settings(w, pages.SettingsParams{
36
-
LoggedInUser: user,
37
-
PubKeys: pubKeys,
38
-
Emails: emails,
39
-
})
40
-
}
41
-
42
-
// buildVerificationEmail creates an email.Email struct for verification emails
43
-
func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email {
44
-
verifyURL := s.verifyUrl(did, emailAddr, code)
45
-
46
-
return email.Email{
47
-
APIKey: s.config.ResendApiKey,
48
-
From: "noreply@notifs.tangled.sh",
49
-
To: emailAddr,
50
-
Subject: "Verify your Tangled email",
51
-
Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
52
-
` + verifyURL,
53
-
Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
54
-
<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
55
-
}
56
-
}
57
-
58
-
// sendVerificationEmail handles the common logic for sending verification emails
59
-
func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
60
-
emailToSend := s.buildVerificationEmail(emailAddr, did, code)
61
-
62
-
err := email.SendEmail(emailToSend)
63
-
if err != nil {
64
-
log.Printf("sending email: %s", err)
65
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
66
-
return err
67
-
}
68
-
69
-
return nil
70
-
}
71
-
72
-
func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) {
73
-
switch r.Method {
74
-
case http.MethodGet:
75
-
s.pages.Notice(w, "settings-emails", "Unimplemented.")
76
-
log.Println("unimplemented")
77
-
return
78
-
case http.MethodPut:
79
-
did := s.auth.GetDid(r)
80
-
emAddr := r.FormValue("email")
81
-
emAddr = strings.TrimSpace(emAddr)
82
-
83
-
if !email.IsValidEmail(emAddr) {
84
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
85
-
return
86
-
}
87
-
88
-
// check if email already exists in database
89
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
90
-
if err != nil && !errors.Is(err, sql.ErrNoRows) {
91
-
log.Printf("checking for existing email: %s", err)
92
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
93
-
return
94
-
}
95
-
96
-
if err == nil {
97
-
if existingEmail.Verified {
98
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
99
-
return
100
-
}
101
-
102
-
s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
103
-
return
104
-
}
105
-
106
-
code := uuid.New().String()
107
-
108
-
// Begin transaction
109
-
tx, err := s.db.Begin()
110
-
if err != nil {
111
-
log.Printf("failed to start transaction: %s", err)
112
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
113
-
return
114
-
}
115
-
defer tx.Rollback()
116
-
117
-
if err := db.AddEmail(tx, db.Email{
118
-
Did: did,
119
-
Address: emAddr,
120
-
Verified: false,
121
-
VerificationCode: code,
122
-
}); err != nil {
123
-
log.Printf("adding email: %s", err)
124
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
125
-
return
126
-
}
127
-
128
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
129
-
return
130
-
}
131
-
132
-
// Commit transaction
133
-
if err := tx.Commit(); err != nil {
134
-
log.Printf("failed to commit transaction: %s", err)
135
-
s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
136
-
return
137
-
}
138
-
139
-
s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
140
-
return
141
-
case http.MethodDelete:
142
-
did := s.auth.GetDid(r)
143
-
emailAddr := r.FormValue("email")
144
-
emailAddr = strings.TrimSpace(emailAddr)
145
-
146
-
// Begin transaction
147
-
tx, err := s.db.Begin()
148
-
if err != nil {
149
-
log.Printf("failed to start transaction: %s", err)
150
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
151
-
return
152
-
}
153
-
defer tx.Rollback()
154
-
155
-
if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
156
-
log.Printf("deleting email: %s", err)
157
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
158
-
return
159
-
}
160
-
161
-
// Commit transaction
162
-
if err := tx.Commit(); err != nil {
163
-
log.Printf("failed to commit transaction: %s", err)
164
-
s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
165
-
return
166
-
}
167
-
168
-
s.pages.HxLocation(w, "/settings")
169
-
return
170
-
}
171
-
}
172
-
173
-
func (s *State) verifyUrl(did string, email string, code string) string {
174
-
var appUrl string
175
-
if s.config.Dev {
176
-
appUrl = "http://" + s.config.ListenAddr
177
-
} else {
178
-
appUrl = "https://tangled.sh"
179
-
}
180
-
181
-
return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
182
-
}
183
-
184
-
func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) {
185
-
q := r.URL.Query()
186
-
187
-
// Get the parameters directly from the query
188
-
emailAddr := q.Get("email")
189
-
did := q.Get("did")
190
-
code := q.Get("code")
191
-
192
-
valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code)
193
-
if err != nil {
194
-
log.Printf("checking email verification: %s", err)
195
-
s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
196
-
return
197
-
}
198
-
199
-
if !valid {
200
-
s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
201
-
return
202
-
}
203
-
204
-
// Mark email as verified in the database
205
-
if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil {
206
-
log.Printf("marking email as verified: %s", err)
207
-
s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
208
-
return
209
-
}
210
-
211
-
http.Redirect(w, r, "/settings", http.StatusSeeOther)
212
-
}
213
-
214
-
func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) {
215
-
if r.Method != http.MethodPost {
216
-
s.pages.Notice(w, "settings-emails-error", "Invalid request method.")
217
-
return
218
-
}
219
-
220
-
did := s.auth.GetDid(r)
221
-
emAddr := r.FormValue("email")
222
-
emAddr = strings.TrimSpace(emAddr)
223
-
224
-
if !email.IsValidEmail(emAddr) {
225
-
s.pages.Notice(w, "settings-emails-error", "Invalid email address.")
226
-
return
227
-
}
228
-
229
-
// Check if email exists and is unverified
230
-
existingEmail, err := db.GetEmail(s.db, did, emAddr)
231
-
if err != nil {
232
-
if errors.Is(err, sql.ErrNoRows) {
233
-
s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
234
-
} else {
235
-
log.Printf("checking for existing email: %s", err)
236
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
237
-
}
238
-
return
239
-
}
240
-
241
-
if existingEmail.Verified {
242
-
s.pages.Notice(w, "settings-emails-error", "This email is already verified.")
243
-
return
244
-
}
245
-
246
-
// Check if last verification email was sent less than 10 minutes ago
247
-
if existingEmail.LastSent != nil {
248
-
timeSinceLastSent := time.Since(*existingEmail.LastSent)
249
-
if timeSinceLastSent < 10*time.Minute {
250
-
waitTime := 10*time.Minute - timeSinceLastSent
251
-
s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
252
-
return
253
-
}
254
-
}
255
-
256
-
// Generate new verification code
257
-
code := uuid.New().String()
258
-
259
-
// Begin transaction
260
-
tx, err := s.db.Begin()
261
-
if err != nil {
262
-
log.Printf("failed to start transaction: %s", err)
263
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
264
-
return
265
-
}
266
-
defer tx.Rollback()
267
-
268
-
// Update the verification code and last sent time
269
-
if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
270
-
log.Printf("updating email verification: %s", err)
271
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
272
-
return
273
-
}
274
-
275
-
// Send verification email
276
-
if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
277
-
return
278
-
}
279
-
280
-
// Commit transaction
281
-
if err := tx.Commit(); err != nil {
282
-
log.Printf("failed to commit transaction: %s", err)
283
-
s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
284
-
return
285
-
}
286
-
287
-
s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
288
-
}
289
-
290
-
func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) {
291
-
did := s.auth.GetDid(r)
292
-
emailAddr := r.FormValue("email")
293
-
emailAddr = strings.TrimSpace(emailAddr)
294
-
295
-
if emailAddr == "" {
296
-
s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
297
-
return
298
-
}
299
-
300
-
if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil {
301
-
log.Printf("setting primary email: %s", err)
302
-
s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
303
-
return
304
-
}
305
-
306
-
s.pages.HxLocation(w, "/settings")
307
-
}
308
-
309
-
func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) {
310
-
switch r.Method {
311
-
case http.MethodGet:
312
-
s.pages.Notice(w, "settings-keys", "Unimplemented.")
313
-
log.Println("unimplemented")
314
-
return
315
-
case http.MethodPut:
316
-
did := s.auth.GetDid(r)
317
-
key := r.FormValue("key")
318
-
key = strings.TrimSpace(key)
319
-
name := r.FormValue("name")
320
-
client, _ := s.auth.AuthorizedClient(r)
321
-
322
-
_, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
323
-
if err != nil {
324
-
log.Printf("parsing public key: %s", err)
325
-
s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
326
-
return
327
-
}
328
-
329
-
rkey := s.TID()
330
-
331
-
tx, err := s.db.Begin()
332
-
if err != nil {
333
-
log.Printf("failed to start tx; adding public key: %s", err)
334
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
335
-
return
336
-
}
337
-
defer tx.Rollback()
338
-
339
-
if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
340
-
log.Printf("adding public key: %s", err)
341
-
s.pages.Notice(w, "settings-keys", "Failed to add public key.")
342
-
return
343
-
}
344
-
345
-
// store in pds too
346
-
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
347
-
Collection: tangled.PublicKeyNSID,
348
-
Repo: did,
349
-
Rkey: rkey,
350
-
Record: &lexutil.LexiconTypeDecoder{
351
-
Val: &tangled.PublicKey{
352
-
Created: time.Now().Format(time.RFC3339),
353
-
Key: key,
354
-
Name: name,
355
-
}},
356
-
})
357
-
// invalid record
358
-
if err != nil {
359
-
log.Printf("failed to create record: %s", err)
360
-
s.pages.Notice(w, "settings-keys", "Failed to create record.")
361
-
return
362
-
}
363
-
364
-
log.Println("created atproto record: ", resp.Uri)
365
-
366
-
err = tx.Commit()
367
-
if err != nil {
368
-
log.Printf("failed to commit tx; adding public key: %s", err)
369
-
s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
370
-
return
371
-
}
372
-
373
-
s.pages.HxLocation(w, "/settings")
374
-
return
375
-
376
-
case http.MethodDelete:
377
-
did := s.auth.GetDid(r)
378
-
q := r.URL.Query()
379
-
380
-
name := q.Get("name")
381
-
rkey := q.Get("rkey")
382
-
key := q.Get("key")
383
-
384
-
log.Println(name)
385
-
log.Println(rkey)
386
-
log.Println(key)
387
-
388
-
client, _ := s.auth.AuthorizedClient(r)
389
-
390
-
if err := db.RemovePublicKey(s.db, did, name, key); err != nil {
391
-
log.Printf("removing public key: %s", err)
392
-
s.pages.Notice(w, "settings-keys", "Failed to remove public key.")
393
-
return
394
-
}
395
-
396
-
if rkey != "" {
397
-
// remove from pds too
398
-
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
399
-
Collection: tangled.PublicKeyNSID,
400
-
Repo: did,
401
-
Rkey: rkey,
402
-
})
403
-
404
-
// invalid record
405
-
if err != nil {
406
-
log.Printf("failed to delete record from PDS: %s", err)
407
-
s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
408
-
return
409
-
}
410
-
}
411
-
log.Println("deleted successfully")
412
-
413
-
s.pages.HxLocation(w, "/settings")
414
-
return
415
-
}
416
-
}
+6
-6
appview/state/signer.go
+6
-6
appview/state/signer.go
···
378
378
return &capabilities, nil
379
379
}
380
380
381
-
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoDiffTreeResponse, error) {
381
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
382
382
const (
383
383
Method = "GET"
384
384
)
···
409
409
}
410
410
defer compareResp.Body.Close()
411
411
412
-
var diffTreeResponse types.RepoDiffTreeResponse
413
-
err = json.Unmarshal(respBody, &diffTreeResponse)
412
+
var formatPatchResponse types.RepoFormatPatchResponse
413
+
err = json.Unmarshal(respBody, &formatPatchResponse)
414
414
if err != nil {
415
-
log.Println("failed to unmarshal diff tree response", err)
416
-
return nil, fmt.Errorf("Failed to compare branches.")
415
+
log.Println("failed to unmarshal format-patch response", err)
416
+
return nil, fmt.Errorf("failed to compare branches.")
417
417
}
418
418
419
-
return &diffTreeResponse, nil
419
+
return &formatPatchResponse, nil
420
420
}
+2
-1
appview/state/star.go
+2
-1
appview/state/star.go
···
9
9
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
lexutil "github.com/bluesky-social/indigo/lex/util"
11
11
tangled "tangled.sh/tangled.sh/core/api/tangled"
12
+
"tangled.sh/tangled.sh/core/appview"
12
13
"tangled.sh/tangled.sh/core/appview/db"
13
14
"tangled.sh/tangled.sh/core/appview/pages"
14
15
)
···
33
34
switch r.Method {
34
35
case http.MethodPost:
35
36
createdAt := time.Now().Format(time.RFC3339)
36
-
rkey := s.TID()
37
+
rkey := appview.TID()
37
38
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
38
39
Collection: tangled.FeedStarNSID,
39
40
Repo: currentUser.Did,
+5
-5
appview/state/state.go
+5
-5
appview/state/state.go
···
55
55
56
56
clock := syntax.NewTIDClock(0)
57
57
58
-
pgs := pages.NewPages()
58
+
pgs := pages.NewPages(config.Dev)
59
59
60
60
resolver := appview.NewResolver()
61
61
···
91
91
return state, nil
92
92
}
93
93
94
-
func (s *State) TID() string {
95
-
return s.tidClock.Next().String()
94
+
func TID(c *syntax.TIDClock) string {
95
+
return c.Next().String()
96
96
}
97
97
98
98
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
···
522
522
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
523
523
Collection: tangled.KnotMemberNSID,
524
524
Repo: currentUser.Did,
525
-
Rkey: s.TID(),
525
+
Rkey: appview.TID(),
526
526
Record: &lexutil.LexiconTypeDecoder{
527
527
Val: &tangled.KnotMember{
528
528
Member: memberIdent.DID.String(),
···
646
646
return
647
647
}
648
648
649
-
rkey := s.TID()
649
+
rkey := appview.TID()
650
650
repo := &db.Repo{
651
651
Did: user.Did,
652
652
Name: repoName,
+11
appview/tid.go
+11
appview/tid.go
+38
cmd/combinediff/main.go
+38
cmd/combinediff/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
+
)
10
+
11
+
func main() {
12
+
if len(os.Args) != 3 {
13
+
fmt.Println("Usage: combinediff <patch1> <patch2>")
14
+
os.Exit(1)
15
+
}
16
+
17
+
patch1, err := os.Open(os.Args[1])
18
+
if err != nil {
19
+
fmt.Println(err)
20
+
}
21
+
patch2, err := os.Open(os.Args[2])
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files1, _, err := gitdiff.Parse(patch1)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
files2, _, err := gitdiff.Parse(patch2)
32
+
if err != nil {
33
+
fmt.Println(err)
34
+
}
35
+
36
+
combined := patchutil.CombineDiff(files1, files2)
37
+
fmt.Println(combined)
38
+
}
+38
cmd/interdiff/main.go
+38
cmd/interdiff/main.go
···
1
+
package main
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
"tangled.sh/tangled.sh/core/patchutil"
9
+
)
10
+
11
+
func main() {
12
+
if len(os.Args) != 3 {
13
+
fmt.Println("Usage: interdiff <patch1> <patch2>")
14
+
os.Exit(1)
15
+
}
16
+
17
+
patch1, err := os.Open(os.Args[1])
18
+
if err != nil {
19
+
fmt.Println(err)
20
+
}
21
+
patch2, err := os.Open(os.Args[2])
22
+
if err != nil {
23
+
fmt.Println(err)
24
+
}
25
+
26
+
files1, _, err := gitdiff.Parse(patch1)
27
+
if err != nil {
28
+
fmt.Println(err)
29
+
}
30
+
31
+
files2, _, err := gitdiff.Parse(patch2)
32
+
if err != nil {
33
+
fmt.Println(err)
34
+
}
35
+
36
+
interDiffResult := patchutil.Interdiff(files1, files2)
37
+
fmt.Println(interDiffResult)
38
+
}
-41
cmd/syntax/chroma.go
-41
cmd/syntax/chroma.go
···
1
-
package main
2
-
3
-
import (
4
-
"flag"
5
-
"fmt"
6
-
"os"
7
-
8
-
"github.com/alecthomas/chroma/v2/formatters/html"
9
-
"github.com/alecthomas/chroma/v2/styles"
10
-
)
11
-
12
-
var (
13
-
lightTheme = "catppuccin-latte"
14
-
darkTheme = "catppuccin-macchiato"
15
-
)
16
-
17
-
func main() {
18
-
outFile := flag.String("out", "", "css output file path")
19
-
flag.Parse()
20
-
21
-
if *outFile == "" {
22
-
fmt.Println("error: output file path is required")
23
-
flag.Usage()
24
-
os.Exit(1)
25
-
}
26
-
27
-
file, err := os.Create(*outFile)
28
-
if err != nil {
29
-
fmt.Printf("error creating file: %v\n", err)
30
-
os.Exit(1)
31
-
}
32
-
defer file.Close()
33
-
34
-
formatter := html.New(html.WithClasses(true))
35
-
36
-
formatter.WriteCSS(file, styles.Get(lightTheme))
37
-
38
-
file.WriteString("\n@media (prefers-color-scheme: dark) {\n")
39
-
formatter.WriteCSS(file, styles.Get(darkTheme))
40
-
file.WriteString("}\n")
41
-
}
+2
-2
docker/docker-compose.yml
+2
-2
docker/docker-compose.yml
···
4
4
context: ..
5
5
dockerfile: docker/Dockerfile
6
6
environment:
7
-
KNOT_SERVER_HOSTNAME: "knot.example.org"
8
-
KNOT_SERVER_SECRET: "secret"
7
+
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
8
+
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
9
9
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
10
10
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
11
11
volumes:
+9
-7
docs/contributing.md
+9
-7
docs/contributing.md
···
11
11
### message format
12
12
13
13
```
14
-
<service/top-level directory>: <package/path>: <short summary of change>
14
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
15
15
16
16
17
-
Optional longer description, if needed. Explain what the change does and
18
-
why, especially if not obvious. Reference relevant issues or PRs when
19
-
applicable. These can be links for now since we don't auto-link
20
-
issues/PRs yet.
17
+
Optional longer description can go here, if necessary. Explain what the
18
+
change does and why, especially if not obvious. Reference relevant
19
+
issues or PRs when applicable. These can be links for now since we don't
20
+
auto-link issues/PRs yet.
21
21
```
22
22
23
23
Here are some examples:
···
35
35
36
36
### general notes
37
37
38
-
- PRs get merged as a single commit, so keep PRs small and focused. Use
39
-
the above guidelines for the PR title and description.
38
+
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
39
+
using `git am`. At present, there is no squashing -- so please author
40
+
your commits as they would appear on `master`, following the above
41
+
guidelines.
40
42
- Use the imperative mood in the summary line (e.g., "fix bug" not
41
43
"fixed bug" or "fixes bug").
42
44
- Try to keep the summary line under 72 characters, but we aren't too
+3
-3
flake.lock
+3
-3
flake.lock
···
48
48
"indigo": {
49
49
"flake": false,
50
50
"locked": {
51
-
"lastModified": 1738491661,
52
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
51
+
"lastModified": 1745333930,
52
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
53
53
"owner": "oppiliappan",
54
54
"repo": "indigo",
55
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
55
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
56
56
"type": "github"
57
57
},
58
58
"original": {
+11
-2
flake.nix
+11
-2
flake.nix
···
49
49
inherit (gitignore.lib) gitignoreSource;
50
50
in {
51
51
overlays.default = final: prev: let
52
-
goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4=";
52
+
goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=";
53
53
buildCmdPackage = name:
54
54
final.buildGoModule {
55
55
pname = name;
···
173
173
${pkgs.air}/bin/air -c /dev/null \
174
174
-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" \
175
175
-build.bin "./out/${name}.out" \
176
-
-build.include_ext "go,html,css"
176
+
-build.include_ext "go"
177
+
'';
178
+
tailwind-watcher =
179
+
pkgs.writeShellScriptBin "run"
180
+
''
181
+
${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css
177
182
'';
178
183
in {
179
184
watch-appview = {
···
183
188
watch-knotserver = {
184
189
type = "app";
185
190
program = ''${air-watcher "knotserver"}/bin/run'';
191
+
};
192
+
watch-tailwind = {
193
+
type = "app";
194
+
program = ''${tailwind-watcher}/bin/run'';
186
195
};
187
196
});
188
197
+3
-3
go.mod
+3
-3
go.mod
···
106
106
go.uber.org/atomic v1.11.0 // indirect
107
107
go.uber.org/multierr v1.11.0 // indirect
108
108
go.uber.org/zap v1.26.0 // indirect
109
-
golang.org/x/crypto v0.36.0 // indirect
110
-
golang.org/x/net v0.37.0 // indirect
111
-
golang.org/x/sys v0.31.0 // indirect
109
+
golang.org/x/crypto v0.37.0 // indirect
110
+
golang.org/x/net v0.39.0 // indirect
111
+
golang.org/x/sys v0.32.0 // indirect
112
112
golang.org/x/time v0.5.0 // indirect
113
113
google.golang.org/protobuf v1.34.2 // indirect
114
114
gopkg.in/warnings.v0 v0.1.2 // indirect
+10
-10
go.sum
+10
-10
go.sum
···
303
303
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
304
304
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
305
305
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
306
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
307
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
306
+
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
307
+
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
308
308
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
309
309
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
310
310
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
327
327
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
328
328
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
329
329
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
330
-
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
331
-
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
330
+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
331
+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
332
332
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
333
333
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
334
334
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
357
357
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
358
358
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
359
359
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
360
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
361
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
360
+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
361
+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
362
362
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
363
363
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
364
364
golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
365
365
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
366
366
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
367
-
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
368
-
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
367
+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
368
+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
369
369
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
370
370
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
371
371
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
372
372
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
373
373
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
374
374
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
375
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
376
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
375
+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
376
+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
377
377
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
378
378
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
379
379
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+168
-5
input.css
+168
-5
input.css
···
35
35
font-size: 15px;
36
36
}
37
37
@supports (font-variation-settings: normal) {
38
-
html {
39
-
font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'tnum' 1;
40
-
}
38
+
html {
39
+
font-feature-settings:
40
+
"ss01" 1,
41
+
"kern" 1,
42
+
"liga" 1,
43
+
"cv05" 1,
44
+
"tnum" 1;
45
+
}
41
46
}
42
47
43
48
a {
···
48
53
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
49
54
}
50
55
input {
51
-
@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;
56
+
@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;
52
57
}
53
58
textarea {
54
-
@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;
59
+
@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;
55
60
}
56
61
details summary::-webkit-details-marker {
57
62
display: none;
···
90
95
}
91
96
}
92
97
}
98
+
99
+
/* Background */ .bg { color: #4c4f69; background-color: #eff1f5; }
100
+
/* PreWrapper */ .chroma { color: #4c4f69; background-color: #eff1f5; }
101
+
/* Error */ .chroma .err { color: #d20f39 }
102
+
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
103
+
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
104
+
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
105
+
/* LineHighlight */ .chroma .hl { background-color: #bcc0cc }
106
+
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
107
+
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 }
108
+
/* Line */ .chroma .line { display: flex; }
109
+
/* Keyword */ .chroma .k { color: #8839ef }
110
+
/* KeywordConstant */ .chroma .kc { color: #fe640b }
111
+
/* KeywordDeclaration */ .chroma .kd { color: #d20f39 }
112
+
/* KeywordNamespace */ .chroma .kn { color: #179299 }
113
+
/* KeywordPseudo */ .chroma .kp { color: #8839ef }
114
+
/* KeywordReserved */ .chroma .kr { color: #8839ef }
115
+
/* KeywordType */ .chroma .kt { color: #d20f39 }
116
+
/* NameAttribute */ .chroma .na { color: #1e66f5 }
117
+
/* NameBuiltin */ .chroma .nb { color: #04a5e5 }
118
+
/* NameBuiltinPseudo */ .chroma .bp { color: #04a5e5 }
119
+
/* NameClass */ .chroma .nc { color: #df8e1d }
120
+
/* NameConstant */ .chroma .no { color: #df8e1d }
121
+
/* NameDecorator */ .chroma .nd { color: #1e66f5; font-weight: bold }
122
+
/* NameEntity */ .chroma .ni { color: #179299 }
123
+
/* NameException */ .chroma .ne { color: #fe640b }
124
+
/* NameFunction */ .chroma .nf { color: #1e66f5 }
125
+
/* NameFunctionMagic */ .chroma .fm { color: #1e66f5 }
126
+
/* NameLabel */ .chroma .nl { color: #04a5e5 }
127
+
/* NameNamespace */ .chroma .nn { color: #fe640b }
128
+
/* NameProperty */ .chroma .py { color: #fe640b }
129
+
/* NameTag */ .chroma .nt { color: #8839ef }
130
+
/* NameVariable */ .chroma .nv { color: #dc8a78 }
131
+
/* NameVariableClass */ .chroma .vc { color: #dc8a78 }
132
+
/* NameVariableGlobal */ .chroma .vg { color: #dc8a78 }
133
+
/* NameVariableInstance */ .chroma .vi { color: #dc8a78 }
134
+
/* NameVariableMagic */ .chroma .vm { color: #dc8a78 }
135
+
/* LiteralString */ .chroma .s { color: #40a02b }
136
+
/* LiteralStringAffix */ .chroma .sa { color: #d20f39 }
137
+
/* LiteralStringBacktick */ .chroma .sb { color: #40a02b }
138
+
/* LiteralStringChar */ .chroma .sc { color: #40a02b }
139
+
/* LiteralStringDelimiter */ .chroma .dl { color: #1e66f5 }
140
+
/* LiteralStringDoc */ .chroma .sd { color: #9ca0b0 }
141
+
/* LiteralStringDouble */ .chroma .s2 { color: #40a02b }
142
+
/* LiteralStringEscape */ .chroma .se { color: #1e66f5 }
143
+
/* LiteralStringHeredoc */ .chroma .sh { color: #9ca0b0 }
144
+
/* LiteralStringInterpol */ .chroma .si { color: #40a02b }
145
+
/* LiteralStringOther */ .chroma .sx { color: #40a02b }
146
+
/* LiteralStringRegex */ .chroma .sr { color: #179299 }
147
+
/* LiteralStringSingle */ .chroma .s1 { color: #40a02b }
148
+
/* LiteralStringSymbol */ .chroma .ss { color: #40a02b }
149
+
/* LiteralNumber */ .chroma .m { color: #fe640b }
150
+
/* LiteralNumberBin */ .chroma .mb { color: #fe640b }
151
+
/* LiteralNumberFloat */ .chroma .mf { color: #fe640b }
152
+
/* LiteralNumberHex */ .chroma .mh { color: #fe640b }
153
+
/* LiteralNumberInteger */ .chroma .mi { color: #fe640b }
154
+
/* LiteralNumberIntegerLong */ .chroma .il { color: #fe640b }
155
+
/* LiteralNumberOct */ .chroma .mo { color: #fe640b }
156
+
/* Operator */ .chroma .o { color: #04a5e5; font-weight: bold }
157
+
/* OperatorWord */ .chroma .ow { color: #04a5e5; font-weight: bold }
158
+
/* Comment */ .chroma .c { color: #9ca0b0; font-style: italic }
159
+
/* CommentHashbang */ .chroma .ch { color: #9ca0b0; font-style: italic }
160
+
/* CommentMultiline */ .chroma .cm { color: #9ca0b0; font-style: italic }
161
+
/* CommentSingle */ .chroma .c1 { color: #9ca0b0; font-style: italic }
162
+
/* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic }
163
+
/* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic }
164
+
/* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic }
165
+
/* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) }
166
+
/* GenericEmph */ .chroma .ge { font-style: italic }
167
+
/* GenericError */ .chroma .gr { color: #d20f39 }
168
+
/* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold }
169
+
/* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) }
170
+
/* GenericStrong */ .chroma .gs { font-weight: bold }
171
+
/* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold }
172
+
/* GenericTraceback */ .chroma .gt { color: #d20f39 }
173
+
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
174
+
175
+
@media (prefers-color-scheme: dark) {
176
+
/* Background */ .bg { color: #cad3f5; background-color: #24273a; }
177
+
/* PreWrapper */ .chroma { color: #cad3f5; background-color: #24273a; }
178
+
/* Error */ .chroma .err { color: #ed8796 }
179
+
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
180
+
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
181
+
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
182
+
/* LineHighlight */ .chroma .hl { background-color: #494d64 }
183
+
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
184
+
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 }
185
+
/* Line */ .chroma .line { display: flex; }
186
+
/* Keyword */ .chroma .k { color: #c6a0f6 }
187
+
/* KeywordConstant */ .chroma .kc { color: #f5a97f }
188
+
/* KeywordDeclaration */ .chroma .kd { color: #ed8796 }
189
+
/* KeywordNamespace */ .chroma .kn { color: #8bd5ca }
190
+
/* KeywordPseudo */ .chroma .kp { color: #c6a0f6 }
191
+
/* KeywordReserved */ .chroma .kr { color: #c6a0f6 }
192
+
/* KeywordType */ .chroma .kt { color: #ed8796 }
193
+
/* NameAttribute */ .chroma .na { color: #8aadf4 }
194
+
/* NameBuiltin */ .chroma .nb { color: #91d7e3 }
195
+
/* NameBuiltinPseudo */ .chroma .bp { color: #91d7e3 }
196
+
/* NameClass */ .chroma .nc { color: #eed49f }
197
+
/* NameConstant */ .chroma .no { color: #eed49f }
198
+
/* NameDecorator */ .chroma .nd { color: #8aadf4; font-weight: bold }
199
+
/* NameEntity */ .chroma .ni { color: #8bd5ca }
200
+
/* NameException */ .chroma .ne { color: #f5a97f }
201
+
/* NameFunction */ .chroma .nf { color: #8aadf4 }
202
+
/* NameFunctionMagic */ .chroma .fm { color: #8aadf4 }
203
+
/* NameLabel */ .chroma .nl { color: #91d7e3 }
204
+
/* NameNamespace */ .chroma .nn { color: #f5a97f }
205
+
/* NameProperty */ .chroma .py { color: #f5a97f }
206
+
/* NameTag */ .chroma .nt { color: #c6a0f6 }
207
+
/* NameVariable */ .chroma .nv { color: #f4dbd6 }
208
+
/* NameVariableClass */ .chroma .vc { color: #f4dbd6 }
209
+
/* NameVariableGlobal */ .chroma .vg { color: #f4dbd6 }
210
+
/* NameVariableInstance */ .chroma .vi { color: #f4dbd6 }
211
+
/* NameVariableMagic */ .chroma .vm { color: #f4dbd6 }
212
+
/* LiteralString */ .chroma .s { color: #a6da95 }
213
+
/* LiteralStringAffix */ .chroma .sa { color: #ed8796 }
214
+
/* LiteralStringBacktick */ .chroma .sb { color: #a6da95 }
215
+
/* LiteralStringChar */ .chroma .sc { color: #a6da95 }
216
+
/* LiteralStringDelimiter */ .chroma .dl { color: #8aadf4 }
217
+
/* LiteralStringDoc */ .chroma .sd { color: #6e738d }
218
+
/* LiteralStringDouble */ .chroma .s2 { color: #a6da95 }
219
+
/* LiteralStringEscape */ .chroma .se { color: #8aadf4 }
220
+
/* LiteralStringHeredoc */ .chroma .sh { color: #6e738d }
221
+
/* LiteralStringInterpol */ .chroma .si { color: #a6da95 }
222
+
/* LiteralStringOther */ .chroma .sx { color: #a6da95 }
223
+
/* LiteralStringRegex */ .chroma .sr { color: #8bd5ca }
224
+
/* LiteralStringSingle */ .chroma .s1 { color: #a6da95 }
225
+
/* LiteralStringSymbol */ .chroma .ss { color: #a6da95 }
226
+
/* LiteralNumber */ .chroma .m { color: #f5a97f }
227
+
/* LiteralNumberBin */ .chroma .mb { color: #f5a97f }
228
+
/* LiteralNumberFloat */ .chroma .mf { color: #f5a97f }
229
+
/* LiteralNumberHex */ .chroma .mh { color: #f5a97f }
230
+
/* LiteralNumberInteger */ .chroma .mi { color: #f5a97f }
231
+
/* LiteralNumberIntegerLong */ .chroma .il { color: #f5a97f }
232
+
/* LiteralNumberOct */ .chroma .mo { color: #f5a97f }
233
+
/* Operator */ .chroma .o { color: #91d7e3; font-weight: bold }
234
+
/* OperatorWord */ .chroma .ow { color: #91d7e3; font-weight: bold }
235
+
/* Comment */ .chroma .c { color: #6e738d; font-style: italic }
236
+
/* CommentHashbang */ .chroma .ch { color: #6e738d; font-style: italic }
237
+
/* CommentMultiline */ .chroma .cm { color: #6e738d; font-style: italic }
238
+
/* CommentSingle */ .chroma .c1 { color: #6e738d; font-style: italic }
239
+
/* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic }
240
+
/* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic }
241
+
/* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic }
242
+
/* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) }
243
+
/* GenericEmph */ .chroma .ge { font-style: italic }
244
+
/* GenericError */ .chroma .gr { color: #ed8796 }
245
+
/* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold }
246
+
/* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) }
247
+
/* GenericStrong */ .chroma .gs { font-weight: bold }
248
+
/* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold }
249
+
/* GenericTraceback */ .chroma .gt { color: #ed8796 }
250
+
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
251
+
}
252
+
253
+
.chroma .line:has(.ln:target) {
254
+
@apply bg-amber-400/30 dark:bg-amber-500/20
255
+
}
+31
knotserver/git/diff.go
+31
knotserver/git/diff.go
···
1
1
package git
2
2
3
3
import (
4
+
"bytes"
4
5
"fmt"
5
6
"log"
7
+
"os"
8
+
"os/exec"
6
9
"strings"
7
10
8
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
12
"github.com/go-git/go-git/v5/plumbing"
10
13
"github.com/go-git/go-git/v5/plumbing/object"
14
+
"tangled.sh/tangled.sh/core/patchutil"
11
15
"tangled.sh/tangled.sh/core/types"
12
16
)
13
17
···
118
122
Patch: patch.String(),
119
123
Diff: diffs,
120
124
}, nil
125
+
}
126
+
127
+
// FormatPatch generates a git-format-patch output between two commits,
128
+
// and returns the raw format-patch series, a parsed FormatPatch and an error.
129
+
func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) {
130
+
var stdout bytes.Buffer
131
+
cmd := exec.Command(
132
+
"git",
133
+
"-C",
134
+
g.path,
135
+
"format-patch",
136
+
fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()),
137
+
"--stdout",
138
+
)
139
+
cmd.Stdout = &stdout
140
+
cmd.Stderr = os.Stderr
141
+
err := cmd.Run()
142
+
if err != nil {
143
+
return "", nil, err
144
+
}
145
+
146
+
formatPatch, err := patchutil.ExtractPatches(stdout.String())
147
+
if err != nil {
148
+
return "", nil, err
149
+
}
150
+
151
+
return stdout.String(), formatPatch, nil
121
152
}
122
153
123
154
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
+17
-2
knotserver/git/merge.go
+17
-2
knotserver/git/merge.go
···
10
10
11
11
"github.com/go-git/go-git/v5"
12
12
"github.com/go-git/go-git/v5/plumbing"
13
+
"tangled.sh/tangled.sh/core/patchutil"
13
14
)
14
15
15
16
type ErrMerge struct {
···
30
31
CommitBody string
31
32
AuthorName string
32
33
AuthorEmail string
34
+
FormatPatch bool
33
35
}
34
36
35
37
func (e ErrMerge) Error() string {
···
89
91
if checkOnly {
90
92
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
91
93
} else {
92
-
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
94
+
// if patch is a format-patch, apply using 'git am'
95
+
if opts.FormatPatch {
96
+
amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
97
+
amCmd.Stderr = &stderr
98
+
if err := amCmd.Run(); err != nil {
99
+
return fmt.Errorf("patch application failed: %s", stderr.String())
100
+
}
101
+
return nil
102
+
}
93
103
104
+
// else, apply using 'git apply' and commit it manually
105
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
94
106
if opts != nil {
95
107
applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
96
108
applyCmd.Stderr = &stderr
···
153
165
}
154
166
155
167
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
168
+
var opts MergeOptions
169
+
opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
170
+
156
171
patchFile, err := g.createTempFileWithPatch(patchData)
157
172
if err != nil {
158
173
return &ErrMerge{
···
171
186
}
172
187
defer os.RemoveAll(tmpDir)
173
188
174
-
return g.applyPatch(tmpDir, patchFile, true, nil)
189
+
return g.applyPatch(tmpDir, patchFile, true, &opts)
175
190
}
176
191
177
192
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+12
-2
knotserver/routes.go
+12
-2
knotserver/routes.go
···
24
24
"github.com/go-git/go-git/v5/plumbing/object"
25
25
"tangled.sh/tangled.sh/core/knotserver/db"
26
26
"tangled.sh/tangled.sh/core/knotserver/git"
27
+
"tangled.sh/tangled.sh/core/patchutil"
27
28
"tangled.sh/tangled.sh/core/types"
28
29
)
29
30
···
36
37
37
38
capabilities := map[string]any{
38
39
"pull_requests": map[string]any{
40
+
"format_patch": true,
39
41
"patch_submissions": true,
40
42
"branch_submissions": true,
41
43
"fork_submissions": true,
···
687
689
notFound(w)
688
690
return
689
691
}
692
+
693
+
mo.FormatPatch = patchutil.IsFormatPatch(patch)
694
+
690
695
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
691
696
var mergeErr *git.ErrMerge
692
697
if errors.As(err, &mergeErr) {
···
804
809
return
805
810
}
806
811
807
-
difftree, err := gr.DiffTree(mergeBase, commit2)
812
+
rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2)
808
813
if err != nil {
809
814
l.Error("error comparing revisions", "msg", err.Error())
810
815
writeError(w, "error comparing revisions", http.StatusBadRequest)
811
816
return
812
817
}
813
818
814
-
writeJSON(w, types.RepoDiffTreeResponse{difftree})
819
+
writeJSON(w, types.RepoFormatPatchResponse{
820
+
Rev1: commit1.Hash.String(),
821
+
Rev2: commit2.Hash.String(),
822
+
FormatPatch: formatPatch,
823
+
Patch: rawPatch,
824
+
})
815
825
return
816
826
}
817
827
+168
patchutil/combinediff.go
+168
patchutil/combinediff.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
// original1 -> patch1 -> rev1
11
+
// original2 -> patch2 -> rev2
12
+
//
13
+
// original2 must be equal to rev1, so we can merge them to get maximal context
14
+
//
15
+
// finally,
16
+
// rev2' <- apply(patch2, merged)
17
+
// combineddiff <- diff(rev2', original1)
18
+
func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) {
19
+
fileName := bestName(file1)
20
+
21
+
o1 := CreatePreImage(file1)
22
+
r1 := CreatePostImage(file1)
23
+
o2 := CreatePreImage(file2)
24
+
25
+
merged, err := r1.Merge(&o2)
26
+
if err != nil {
27
+
return nil, err
28
+
}
29
+
30
+
r2Prime, err := merged.Apply(file2)
31
+
if err != nil {
32
+
return nil, err
33
+
}
34
+
35
+
// produce combined diff
36
+
diff, err := Unified(o1.String(), fileName, r2Prime, fileName)
37
+
if err != nil {
38
+
return nil, err
39
+
}
40
+
41
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
42
+
43
+
if len(parsed) != 1 {
44
+
// no diff? the second commit reverted the changes from the first
45
+
return nil, nil
46
+
}
47
+
48
+
return parsed[0], nil
49
+
}
50
+
51
+
// use empty lines for lines we are unaware of
52
+
//
53
+
// this raises an error only if the two patches were invalid or non-contiguous
54
+
func mergeLines(old, new string) (string, error) {
55
+
var i, j int
56
+
57
+
// TODO: use strings.Lines
58
+
linesOld := strings.Split(old, "\n")
59
+
linesNew := strings.Split(new, "\n")
60
+
61
+
result := []string{}
62
+
63
+
for i < len(linesOld) || j < len(linesNew) {
64
+
if i >= len(linesOld) {
65
+
// rest of the file is populated from `new`
66
+
result = append(result, linesNew[j])
67
+
j++
68
+
continue
69
+
}
70
+
71
+
if j >= len(linesNew) {
72
+
// rest of the file is populated from `old`
73
+
result = append(result, linesOld[i])
74
+
i++
75
+
continue
76
+
}
77
+
78
+
oldLine := linesOld[i]
79
+
newLine := linesNew[j]
80
+
81
+
if oldLine != newLine && (oldLine != "" && newLine != "") {
82
+
// context mismatch
83
+
return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine)
84
+
}
85
+
86
+
if oldLine == newLine {
87
+
result = append(result, oldLine)
88
+
} else if oldLine == "" {
89
+
result = append(result, newLine)
90
+
} else if newLine == "" {
91
+
result = append(result, oldLine)
92
+
}
93
+
i++
94
+
j++
95
+
}
96
+
97
+
return strings.Join(result, "\n"), nil
98
+
}
99
+
100
+
func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File {
101
+
fileToIdx1 := make(map[string]int)
102
+
fileToIdx2 := make(map[string]int)
103
+
visited := make(map[string]struct{})
104
+
var result []*gitdiff.File
105
+
106
+
for idx, f := range patch1 {
107
+
fileToIdx1[bestName(f)] = idx
108
+
}
109
+
110
+
for idx, f := range patch2 {
111
+
fileToIdx2[bestName(f)] = idx
112
+
}
113
+
114
+
for _, f1 := range patch1 {
115
+
fileName := bestName(f1)
116
+
if idx, ok := fileToIdx2[fileName]; ok {
117
+
f2 := patch2[idx]
118
+
119
+
// we have f1 and f2, combine them
120
+
combined, err := combineFiles(f1, f2)
121
+
if err != nil {
122
+
fmt.Println(err)
123
+
}
124
+
125
+
result = append(result, combined)
126
+
} else {
127
+
// only in patch1; add as-is
128
+
result = append(result, f1)
129
+
}
130
+
131
+
visited[fileName] = struct{}{}
132
+
}
133
+
134
+
// for all files in patch2 that remain unvisited; we can just add them into the output
135
+
for _, f2 := range patch2 {
136
+
fileName := bestName(f2)
137
+
if _, ok := visited[fileName]; ok {
138
+
continue
139
+
}
140
+
141
+
result = append(result, f2)
142
+
}
143
+
144
+
return result
145
+
}
146
+
147
+
// pairwise combination from first to last patch
148
+
func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File {
149
+
if len(patches) == 0 {
150
+
return nil
151
+
}
152
+
153
+
if len(patches) == 1 {
154
+
return patches[0]
155
+
}
156
+
157
+
combined := combineTwo(patches[0], patches[1])
158
+
159
+
newPatches := [][]*gitdiff.File{}
160
+
newPatches = append(newPatches, combined)
161
+
for i, p := range patches {
162
+
if i >= 2 {
163
+
newPatches = append(newPatches, p)
164
+
}
165
+
}
166
+
167
+
return CombineDiff(newPatches...)
168
+
}
+178
patchutil/image.go
+178
patchutil/image.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"bytes"
5
+
"fmt"
6
+
"strings"
7
+
8
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
+
)
10
+
11
+
type Line struct {
12
+
LineNumber int64
13
+
Content string
14
+
IsUnknown bool
15
+
}
16
+
17
+
func NewLineAt(lineNumber int64, content string) Line {
18
+
return Line{
19
+
LineNumber: lineNumber,
20
+
Content: content,
21
+
IsUnknown: false,
22
+
}
23
+
}
24
+
25
+
type Image struct {
26
+
File string
27
+
Data []*Line
28
+
}
29
+
30
+
func (r *Image) String() string {
31
+
var i, j int64
32
+
var b strings.Builder
33
+
for {
34
+
i += 1
35
+
36
+
if int(j) >= (len(r.Data)) {
37
+
break
38
+
}
39
+
40
+
if r.Data[j].LineNumber == i {
41
+
// b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber))
42
+
b.WriteString(r.Data[j].Content)
43
+
j += 1
44
+
} else {
45
+
//b.WriteString(fmt.Sprintf("%d:\n", i))
46
+
b.WriteString("\n")
47
+
}
48
+
}
49
+
50
+
return b.String()
51
+
}
52
+
53
+
func (r *Image) AddLine(line *Line) {
54
+
r.Data = append(r.Data, line)
55
+
}
56
+
57
+
// rebuild the original file from a patch
58
+
func CreatePreImage(file *gitdiff.File) Image {
59
+
rf := Image{
60
+
File: bestName(file),
61
+
}
62
+
63
+
for _, fragment := range file.TextFragments {
64
+
position := fragment.OldPosition
65
+
for _, line := range fragment.Lines {
66
+
switch line.Op {
67
+
case gitdiff.OpContext:
68
+
rl := NewLineAt(position, line.Line)
69
+
rf.Data = append(rf.Data, &rl)
70
+
position += 1
71
+
case gitdiff.OpDelete:
72
+
rl := NewLineAt(position, line.Line)
73
+
rf.Data = append(rf.Data, &rl)
74
+
position += 1
75
+
case gitdiff.OpAdd:
76
+
// do nothing here
77
+
}
78
+
}
79
+
}
80
+
81
+
return rf
82
+
}
83
+
84
+
// rebuild the revised file from a patch
85
+
func CreatePostImage(file *gitdiff.File) Image {
86
+
rf := Image{
87
+
File: bestName(file),
88
+
}
89
+
90
+
for _, fragment := range file.TextFragments {
91
+
position := fragment.NewPosition
92
+
for _, line := range fragment.Lines {
93
+
switch line.Op {
94
+
case gitdiff.OpContext:
95
+
rl := NewLineAt(position, line.Line)
96
+
rf.Data = append(rf.Data, &rl)
97
+
position += 1
98
+
case gitdiff.OpAdd:
99
+
rl := NewLineAt(position, line.Line)
100
+
rf.Data = append(rf.Data, &rl)
101
+
position += 1
102
+
case gitdiff.OpDelete:
103
+
// do nothing here
104
+
}
105
+
}
106
+
}
107
+
108
+
return rf
109
+
}
110
+
111
+
type MergeError struct {
112
+
msg string
113
+
mismatchingLine int64
114
+
}
115
+
116
+
func (m MergeError) Error() string {
117
+
return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine)
118
+
}
119
+
120
+
// best effort merging of two reconstructed files
121
+
func (this *Image) Merge(other *Image) (*Image, error) {
122
+
mergedFile := Image{}
123
+
124
+
var i, j int64
125
+
126
+
for int(i) < len(this.Data) || int(j) < len(other.Data) {
127
+
if int(i) >= len(this.Data) {
128
+
// first file is done; the rest of the lines from file 2 can go in
129
+
mergedFile.AddLine(other.Data[j])
130
+
j++
131
+
continue
132
+
}
133
+
134
+
if int(j) >= len(other.Data) {
135
+
// first file is done; the rest of the lines from file 2 can go in
136
+
mergedFile.AddLine(this.Data[i])
137
+
i++
138
+
continue
139
+
}
140
+
141
+
line1 := this.Data[i]
142
+
line2 := other.Data[j]
143
+
144
+
if line1.LineNumber == line2.LineNumber {
145
+
if line1.Content != line2.Content {
146
+
return nil, MergeError{
147
+
msg: "mismatching lines, this patch might have undergone rebase",
148
+
mismatchingLine: line1.LineNumber,
149
+
}
150
+
} else {
151
+
mergedFile.AddLine(line1)
152
+
}
153
+
i++
154
+
j++
155
+
} else if line1.LineNumber < line2.LineNumber {
156
+
mergedFile.AddLine(line1)
157
+
i++
158
+
} else {
159
+
mergedFile.AddLine(line2)
160
+
j++
161
+
}
162
+
}
163
+
164
+
return &mergedFile, nil
165
+
}
166
+
167
+
func (r *Image) Apply(patch *gitdiff.File) (string, error) {
168
+
original := r.String()
169
+
var buffer bytes.Buffer
170
+
reader := strings.NewReader(original)
171
+
172
+
err := gitdiff.Apply(&buffer, reader, patch)
173
+
if err != nil {
174
+
return "", err
175
+
}
176
+
177
+
return buffer.String(), nil
178
+
}
+244
patchutil/interdiff.go
+244
patchutil/interdiff.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
7
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
8
+
)
9
+
10
+
type InterdiffResult struct {
11
+
Files []*InterdiffFile
12
+
}
13
+
14
+
func (i *InterdiffResult) AffectedFiles() []string {
15
+
files := make([]string, len(i.Files))
16
+
for _, f := range i.Files {
17
+
files = append(files, f.Name)
18
+
}
19
+
return files
20
+
}
21
+
22
+
func (i *InterdiffResult) String() string {
23
+
var b strings.Builder
24
+
for _, f := range i.Files {
25
+
b.WriteString(f.String())
26
+
b.WriteString("\n")
27
+
}
28
+
29
+
return b.String()
30
+
}
31
+
32
+
type InterdiffFile struct {
33
+
*gitdiff.File
34
+
Name string
35
+
Status InterdiffFileStatus
36
+
}
37
+
38
+
func (s *InterdiffFile) String() string {
39
+
var b strings.Builder
40
+
b.WriteString(s.Status.String())
41
+
b.WriteString(" ")
42
+
43
+
if s.File != nil {
44
+
b.WriteString(bestName(s.File))
45
+
b.WriteString("\n")
46
+
b.WriteString(s.File.String())
47
+
}
48
+
49
+
return b.String()
50
+
}
51
+
52
+
type InterdiffFileStatus struct {
53
+
StatusKind StatusKind
54
+
Error error
55
+
}
56
+
57
+
func (s *InterdiffFileStatus) String() string {
58
+
kind := s.StatusKind.String()
59
+
if s.Error != nil {
60
+
return fmt.Sprintf("%s [%s]", kind, s.Error.Error())
61
+
} else {
62
+
return kind
63
+
}
64
+
}
65
+
66
+
func (s *InterdiffFileStatus) IsOk() bool {
67
+
return s.StatusKind == StatusOk
68
+
}
69
+
70
+
func (s *InterdiffFileStatus) IsUnchanged() bool {
71
+
return s.StatusKind == StatusUnchanged
72
+
}
73
+
74
+
func (s *InterdiffFileStatus) IsOnlyInOne() bool {
75
+
return s.StatusKind == StatusOnlyInOne
76
+
}
77
+
78
+
func (s *InterdiffFileStatus) IsOnlyInTwo() bool {
79
+
return s.StatusKind == StatusOnlyInTwo
80
+
}
81
+
82
+
func (s *InterdiffFileStatus) IsRebased() bool {
83
+
return s.StatusKind == StatusRebased
84
+
}
85
+
86
+
func (s *InterdiffFileStatus) IsError() bool {
87
+
return s.StatusKind == StatusError
88
+
}
89
+
90
+
type StatusKind int
91
+
92
+
func (k StatusKind) String() string {
93
+
switch k {
94
+
case StatusOnlyInOne:
95
+
return "only in one"
96
+
case StatusOnlyInTwo:
97
+
return "only in two"
98
+
case StatusUnchanged:
99
+
return "unchanged"
100
+
case StatusRebased:
101
+
return "rebased"
102
+
case StatusError:
103
+
return "error"
104
+
default:
105
+
return "changed"
106
+
}
107
+
}
108
+
109
+
const (
110
+
StatusOk StatusKind = iota
111
+
StatusOnlyInOne
112
+
StatusOnlyInTwo
113
+
StatusUnchanged
114
+
StatusRebased
115
+
StatusError
116
+
)
117
+
118
+
func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile {
119
+
re1 := CreatePreImage(f1)
120
+
re2 := CreatePreImage(f2)
121
+
122
+
interdiffFile := InterdiffFile{
123
+
Name: bestName(f1),
124
+
}
125
+
126
+
merged, err := re1.Merge(&re2)
127
+
if err != nil {
128
+
interdiffFile.Status = InterdiffFileStatus{
129
+
StatusKind: StatusRebased,
130
+
Error: err,
131
+
}
132
+
return &interdiffFile
133
+
}
134
+
135
+
rev1, err := merged.Apply(f1)
136
+
if err != nil {
137
+
interdiffFile.Status = InterdiffFileStatus{
138
+
StatusKind: StatusError,
139
+
Error: err,
140
+
}
141
+
return &interdiffFile
142
+
}
143
+
144
+
rev2, err := merged.Apply(f2)
145
+
if err != nil {
146
+
interdiffFile.Status = InterdiffFileStatus{
147
+
StatusKind: StatusError,
148
+
Error: err,
149
+
}
150
+
return &interdiffFile
151
+
}
152
+
153
+
diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2))
154
+
if err != nil {
155
+
interdiffFile.Status = InterdiffFileStatus{
156
+
StatusKind: StatusError,
157
+
Error: err,
158
+
}
159
+
return &interdiffFile
160
+
}
161
+
162
+
parsed, _, err := gitdiff.Parse(strings.NewReader(diff))
163
+
if err != nil {
164
+
interdiffFile.Status = InterdiffFileStatus{
165
+
StatusKind: StatusError,
166
+
Error: err,
167
+
}
168
+
return &interdiffFile
169
+
}
170
+
171
+
if len(parsed) != 1 {
172
+
// files are identical?
173
+
interdiffFile.Status = InterdiffFileStatus{
174
+
StatusKind: StatusUnchanged,
175
+
}
176
+
return &interdiffFile
177
+
}
178
+
179
+
if interdiffFile.Status.StatusKind == StatusOk {
180
+
interdiffFile.File = parsed[0]
181
+
}
182
+
183
+
return &interdiffFile
184
+
}
185
+
186
+
func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult {
187
+
fileToIdx1 := make(map[string]int)
188
+
fileToIdx2 := make(map[string]int)
189
+
visited := make(map[string]struct{})
190
+
var result InterdiffResult
191
+
192
+
for idx, f := range patch1 {
193
+
fileToIdx1[bestName(f)] = idx
194
+
}
195
+
196
+
for idx, f := range patch2 {
197
+
fileToIdx2[bestName(f)] = idx
198
+
}
199
+
200
+
for _, f1 := range patch1 {
201
+
var interdiffFile *InterdiffFile
202
+
203
+
fileName := bestName(f1)
204
+
if idx, ok := fileToIdx2[fileName]; ok {
205
+
f2 := patch2[idx]
206
+
207
+
// we have f1 and f2, calculate interdiff
208
+
interdiffFile = interdiffFiles(f1, f2)
209
+
} else {
210
+
// only in patch 1, this change would have to be "inverted" to dissapear
211
+
// from patch 2, so we reverseDiff(f1)
212
+
reverseDiff(f1)
213
+
214
+
interdiffFile = &InterdiffFile{
215
+
File: f1,
216
+
Name: fileName,
217
+
Status: InterdiffFileStatus{
218
+
StatusKind: StatusOnlyInOne,
219
+
},
220
+
}
221
+
}
222
+
223
+
result.Files = append(result.Files, interdiffFile)
224
+
visited[fileName] = struct{}{}
225
+
}
226
+
227
+
// for all files in patch2 that remain unvisited; we can just add them into the output
228
+
for _, f2 := range patch2 {
229
+
fileName := bestName(f2)
230
+
if _, ok := visited[fileName]; ok {
231
+
continue
232
+
}
233
+
234
+
result.Files = append(result.Files, &InterdiffFile{
235
+
File: f2,
236
+
Name: fileName,
237
+
Status: InterdiffFileStatus{
238
+
StatusKind: StatusOnlyInTwo,
239
+
},
240
+
})
241
+
}
242
+
243
+
return &result
244
+
}
+196
patchutil/patchutil.go
+196
patchutil/patchutil.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"fmt"
5
+
"os"
6
+
"os/exec"
7
+
"regexp"
8
+
"strings"
9
+
10
+
"github.com/bluekeyes/go-gitdiff/gitdiff"
11
+
)
12
+
13
+
type FormatPatch struct {
14
+
Files []*gitdiff.File
15
+
*gitdiff.PatchHeader
16
+
}
17
+
18
+
func ExtractPatches(formatPatch string) ([]FormatPatch, error) {
19
+
patches := splitFormatPatch(formatPatch)
20
+
21
+
result := []FormatPatch{}
22
+
23
+
for _, patch := range patches {
24
+
files, headerStr, err := gitdiff.Parse(strings.NewReader(patch))
25
+
if err != nil {
26
+
return nil, fmt.Errorf("failed to parse patch: %w", err)
27
+
}
28
+
29
+
header, err := gitdiff.ParsePatchHeader(headerStr)
30
+
if err != nil {
31
+
return nil, fmt.Errorf("failed to parse patch header: %w", err)
32
+
}
33
+
34
+
result = append(result, FormatPatch{
35
+
Files: files,
36
+
PatchHeader: header,
37
+
})
38
+
}
39
+
40
+
return result, nil
41
+
}
42
+
43
+
// IsPatchValid checks if the given patch string is valid.
44
+
// It performs very basic sniffing for either git-diff or git-format-patch
45
+
// header lines. For format patches, it attempts to extract and validate each one.
46
+
func IsPatchValid(patch string) bool {
47
+
if len(patch) == 0 {
48
+
return false
49
+
}
50
+
51
+
lines := strings.Split(patch, "\n")
52
+
if len(lines) < 2 {
53
+
return false
54
+
}
55
+
56
+
firstLine := strings.TrimSpace(lines[0])
57
+
58
+
// check if it's a git diff
59
+
if strings.HasPrefix(firstLine, "diff ") ||
60
+
strings.HasPrefix(firstLine, "--- ") ||
61
+
strings.HasPrefix(firstLine, "Index: ") ||
62
+
strings.HasPrefix(firstLine, "+++ ") ||
63
+
strings.HasPrefix(firstLine, "@@ ") {
64
+
return true
65
+
}
66
+
67
+
// check if it's format-patch
68
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") ||
69
+
strings.HasPrefix(firstLine, "From: ") {
70
+
// ExtractPatches already runs it through gitdiff.Parse so if that errors,
71
+
// it's safe to say it's broken.
72
+
patches, err := ExtractPatches(patch)
73
+
if err != nil {
74
+
return false
75
+
}
76
+
return len(patches) > 0
77
+
}
78
+
79
+
return false
80
+
}
81
+
82
+
func IsFormatPatch(patch string) bool {
83
+
lines := strings.Split(patch, "\n")
84
+
if len(lines) < 2 {
85
+
return false
86
+
}
87
+
88
+
firstLine := strings.TrimSpace(lines[0])
89
+
if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") {
90
+
return true
91
+
}
92
+
93
+
headerCount := 0
94
+
for i := range min(10, len(lines)) {
95
+
line := strings.TrimSpace(lines[i])
96
+
if strings.HasPrefix(line, "From: ") ||
97
+
strings.HasPrefix(line, "Date: ") ||
98
+
strings.HasPrefix(line, "Subject: ") ||
99
+
strings.HasPrefix(line, "commit ") {
100
+
headerCount++
101
+
}
102
+
}
103
+
104
+
return headerCount >= 2
105
+
}
106
+
107
+
func splitFormatPatch(patchText string) []string {
108
+
re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`)
109
+
110
+
indexes := re.FindAllStringIndex(patchText, -1)
111
+
112
+
if len(indexes) == 0 {
113
+
return []string{}
114
+
}
115
+
116
+
patches := make([]string, len(indexes))
117
+
118
+
for i := range indexes {
119
+
startPos := indexes[i][0]
120
+
endPos := len(patchText)
121
+
122
+
if i < len(indexes)-1 {
123
+
endPos = indexes[i+1][0]
124
+
}
125
+
126
+
patches[i] = strings.TrimSpace(patchText[startPos:endPos])
127
+
}
128
+
return patches
129
+
}
130
+
131
+
func bestName(file *gitdiff.File) string {
132
+
if file.IsDelete {
133
+
return file.OldName
134
+
} else {
135
+
return file.NewName
136
+
}
137
+
}
138
+
139
+
// in-place reverse of a diff
140
+
func reverseDiff(file *gitdiff.File) {
141
+
file.OldName, file.NewName = file.NewName, file.OldName
142
+
file.OldMode, file.NewMode = file.NewMode, file.OldMode
143
+
file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment
144
+
145
+
for _, fragment := range file.TextFragments {
146
+
// swap postions
147
+
fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition
148
+
fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines
149
+
fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded
150
+
151
+
for i := range fragment.Lines {
152
+
switch fragment.Lines[i].Op {
153
+
case gitdiff.OpAdd:
154
+
fragment.Lines[i].Op = gitdiff.OpDelete
155
+
case gitdiff.OpDelete:
156
+
fragment.Lines[i].Op = gitdiff.OpAdd
157
+
default:
158
+
// do nothing
159
+
}
160
+
}
161
+
}
162
+
}
163
+
164
+
func Unified(oldText, oldFile, newText, newFile string) (string, error) {
165
+
oldTemp, err := os.CreateTemp("", "old_*")
166
+
if err != nil {
167
+
return "", fmt.Errorf("failed to create temp file for oldText: %w", err)
168
+
}
169
+
defer os.Remove(oldTemp.Name())
170
+
if _, err := oldTemp.WriteString(oldText); err != nil {
171
+
return "", fmt.Errorf("failed to write to old temp file: %w", err)
172
+
}
173
+
oldTemp.Close()
174
+
175
+
newTemp, err := os.CreateTemp("", "new_*")
176
+
if err != nil {
177
+
return "", fmt.Errorf("failed to create temp file for newText: %w", err)
178
+
}
179
+
defer os.Remove(newTemp.Name())
180
+
if _, err := newTemp.WriteString(newText); err != nil {
181
+
return "", fmt.Errorf("failed to write to new temp file: %w", err)
182
+
}
183
+
newTemp.Close()
184
+
185
+
cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name())
186
+
output, err := cmd.CombinedOutput()
187
+
188
+
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
189
+
return string(output), nil
190
+
}
191
+
if err != nil {
192
+
return "", fmt.Errorf("diff command failed: %w", err)
193
+
}
194
+
195
+
return string(output), nil
196
+
}
+324
patchutil/patchutil_test.go
+324
patchutil/patchutil_test.go
···
1
+
package patchutil
2
+
3
+
import (
4
+
"reflect"
5
+
"testing"
6
+
)
7
+
8
+
func TestIsPatchValid(t *testing.T) {
9
+
tests := []struct {
10
+
name string
11
+
patch string
12
+
expected bool
13
+
}{
14
+
{
15
+
name: `empty patch`,
16
+
patch: ``,
17
+
expected: false,
18
+
},
19
+
{
20
+
name: `single line patch`,
21
+
patch: `single line`,
22
+
expected: false,
23
+
},
24
+
{
25
+
name: `valid diff patch`,
26
+
patch: `diff --git a/file.txt b/file.txt
27
+
index abc..def 100644
28
+
--- a/file.txt
29
+
+++ b/file.txt
30
+
@@ -1,3 +1,3 @@
31
+
-old line
32
+
+new line
33
+
context`,
34
+
expected: true,
35
+
},
36
+
{
37
+
name: `valid patch starting with ---`,
38
+
patch: `--- a/file.txt
39
+
+++ b/file.txt
40
+
@@ -1,3 +1,3 @@
41
+
-old line
42
+
+new line
43
+
context`,
44
+
expected: true,
45
+
},
46
+
{
47
+
name: `valid patch starting with Index`,
48
+
patch: `Index: file.txt
49
+
==========
50
+
--- a/file.txt
51
+
+++ b/file.txt
52
+
@@ -1,3 +1,3 @@
53
+
-old line
54
+
+new line
55
+
context`,
56
+
expected: true,
57
+
},
58
+
{
59
+
name: `valid patch starting with +++`,
60
+
patch: `+++ b/file.txt
61
+
--- a/file.txt
62
+
@@ -1,3 +1,3 @@
63
+
-old line
64
+
+new line
65
+
context`,
66
+
expected: true,
67
+
},
68
+
{
69
+
name: `valid patch starting with @@`,
70
+
patch: `@@ -1,3 +1,3 @@
71
+
-old line
72
+
+new line
73
+
context
74
+
`,
75
+
expected: true,
76
+
},
77
+
{
78
+
name: `valid format patch`,
79
+
patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
80
+
From: Author <author@example.com>
81
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
82
+
Subject: [PATCH] Example patch
83
+
84
+
diff --git a/file.txt b/file.txt
85
+
index 123456..789012 100644
86
+
--- a/file.txt
87
+
+++ b/file.txt
88
+
@@ -1 +1 @@
89
+
-old content
90
+
+new content
91
+
--
92
+
2.48.1`,
93
+
expected: true,
94
+
},
95
+
{
96
+
name: `invalid format patch`,
97
+
patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001
98
+
From: Author <author@example.com>
99
+
This is not a valid patch format`,
100
+
expected: false,
101
+
},
102
+
{
103
+
name: `not a patch at all`,
104
+
patch: `This is
105
+
just some
106
+
random text
107
+
that isn't a patch`,
108
+
expected: false,
109
+
},
110
+
}
111
+
112
+
for _, tt := range tests {
113
+
t.Run(tt.name, func(t *testing.T) {
114
+
result := IsPatchValid(tt.patch)
115
+
if result != tt.expected {
116
+
t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected)
117
+
}
118
+
})
119
+
}
120
+
}
121
+
122
+
func TestSplitPatches(t *testing.T) {
123
+
tests := []struct {
124
+
name string
125
+
input string
126
+
expected []string
127
+
}{
128
+
{
129
+
name: "Empty input",
130
+
input: "",
131
+
expected: []string{},
132
+
},
133
+
{
134
+
name: "No valid patches",
135
+
input: "This is not a \nJust some random text",
136
+
expected: []string{},
137
+
},
138
+
{
139
+
name: "Single patch",
140
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
141
+
From: Author <author@example.com>
142
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
143
+
Subject: [PATCH] Example patch
144
+
145
+
diff --git a/file.txt b/file.txt
146
+
index 123456..789012 100644
147
+
--- a/file.txt
148
+
+++ b/file.txt
149
+
@@ -1 +1 @@
150
+
-old content
151
+
+new content
152
+
--
153
+
2.48.1`,
154
+
expected: []string{
155
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
156
+
From: Author <author@example.com>
157
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
158
+
Subject: [PATCH] Example patch
159
+
160
+
diff --git a/file.txt b/file.txt
161
+
index 123456..789012 100644
162
+
--- a/file.txt
163
+
+++ b/file.txt
164
+
@@ -1 +1 @@
165
+
-old content
166
+
+new content
167
+
--
168
+
2.48.1`,
169
+
},
170
+
},
171
+
{
172
+
name: "Two patches",
173
+
input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
174
+
From: Author <author@example.com>
175
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
176
+
Subject: [PATCH 1/2] First patch
177
+
178
+
diff --git a/file1.txt b/file1.txt
179
+
index 123456..789012 100644
180
+
--- a/file1.txt
181
+
+++ b/file1.txt
182
+
@@ -1 +1 @@
183
+
-old content
184
+
+new content
185
+
--
186
+
2.48.1
187
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
188
+
From: Author <author@example.com>
189
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
190
+
Subject: [PATCH 2/2] Second patch
191
+
192
+
diff --git a/file2.txt b/file2.txt
193
+
index abcdef..ghijkl 100644
194
+
--- a/file2.txt
195
+
+++ b/file2.txt
196
+
@@ -1 +1 @@
197
+
-foo bar
198
+
+baz qux
199
+
--
200
+
2.48.1`,
201
+
expected: []string{
202
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
203
+
From: Author <author@example.com>
204
+
Date: Wed, 16 Apr 2025 11:01:00 +0300
205
+
Subject: [PATCH 1/2] First patch
206
+
207
+
diff --git a/file1.txt b/file1.txt
208
+
index 123456..789012 100644
209
+
--- a/file1.txt
210
+
+++ b/file1.txt
211
+
@@ -1 +1 @@
212
+
-old content
213
+
+new content
214
+
--
215
+
2.48.1`,
216
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
217
+
From: Author <author@example.com>
218
+
Date: Wed, 16 Apr 2025 11:03:11 +0300
219
+
Subject: [PATCH 2/2] Second patch
220
+
221
+
diff --git a/file2.txt b/file2.txt
222
+
index abcdef..ghijkl 100644
223
+
--- a/file2.txt
224
+
+++ b/file2.txt
225
+
@@ -1 +1 @@
226
+
-foo bar
227
+
+baz qux
228
+
--
229
+
2.48.1`,
230
+
},
231
+
},
232
+
{
233
+
name: "Patches with additional text between them",
234
+
input: `Some text before the patches
235
+
236
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
237
+
From: Author <author@example.com>
238
+
Subject: [PATCH] First patch
239
+
240
+
diff content here
241
+
--
242
+
2.48.1
243
+
244
+
Some text between patches
245
+
246
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
247
+
From: Author <author@example.com>
248
+
Subject: [PATCH] Second patch
249
+
250
+
more diff content
251
+
--
252
+
2.48.1
253
+
254
+
Text after patches`,
255
+
expected: []string{
256
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
257
+
From: Author <author@example.com>
258
+
Subject: [PATCH] First patch
259
+
260
+
diff content here
261
+
--
262
+
2.48.1
263
+
264
+
Some text between patches`,
265
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
266
+
From: Author <author@example.com>
267
+
Subject: [PATCH] Second patch
268
+
269
+
more diff content
270
+
--
271
+
2.48.1
272
+
273
+
Text after patches`,
274
+
},
275
+
},
276
+
{
277
+
name: "Patches with whitespace padding",
278
+
input: `
279
+
280
+
From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
281
+
From: Author <author@example.com>
282
+
Subject: Patch
283
+
284
+
content
285
+
--
286
+
2.48.1
287
+
288
+
289
+
From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
290
+
From: Author <author@example.com>
291
+
Subject: Another patch
292
+
293
+
content
294
+
--
295
+
2.48.1
296
+
`,
297
+
expected: []string{
298
+
`From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001
299
+
From: Author <author@example.com>
300
+
Subject: Patch
301
+
302
+
content
303
+
--
304
+
2.48.1`,
305
+
`From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001
306
+
From: Author <author@example.com>
307
+
Subject: Another patch
308
+
309
+
content
310
+
--
311
+
2.48.1`,
312
+
},
313
+
},
314
+
}
315
+
316
+
for _, tt := range tests {
317
+
t.Run(tt.name, func(t *testing.T) {
318
+
result := splitFormatPatch(tt.input)
319
+
if !reflect.DeepEqual(result, tt.expected) {
320
+
t.Errorf("splitPatches() = %v, want %v", result, tt.expected)
321
+
}
322
+
})
323
+
}
324
+
}
+1
types/capabilities.go
+1
types/capabilities.go
+14
types/diff.go
+14
types/diff.go
···
59
59
Patch string `json:"patch"`
60
60
Diff []*gitdiff.File `json:"diff"`
61
61
}
62
+
63
+
func (d *NiceDiff) ChangedFiles() []string {
64
+
files := make([]string, len(d.Diff))
65
+
66
+
for i, f := range d.Diff {
67
+
if f.IsDelete {
68
+
files[i] = f.Name.Old
69
+
} else {
70
+
files[i] = f.Name.New
71
+
}
72
+
}
73
+
74
+
return files
75
+
}
+6
-2
types/repo.go
+6
-2
types/repo.go
···
2
2
3
3
import (
4
4
"github.com/go-git/go-git/v5/plumbing/object"
5
+
"tangled.sh/tangled.sh/core/patchutil"
5
6
)
6
7
7
8
type RepoIndexResponse struct {
···
32
33
Diff *NiceDiff `json:"diff,omitempty"`
33
34
}
34
35
35
-
type RepoDiffTreeResponse struct {
36
-
DiffTree *DiffTree `json:"difftree,omitempty"`
36
+
type RepoFormatPatchResponse struct {
37
+
Rev1 string `json:"rev1,omitempty"`
38
+
Rev2 string `json:"rev2,omitempty"`
39
+
FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"`
40
+
Patch string `json:"patch,omitempty"`
37
41
}
38
42
39
43
type RepoTreeResponse struct {