+246
-45
api/tangled/cbor_gen.go
+246
-45
api/tangled/cbor_gen.go
···
1753
1753
}
1754
1754
1755
1755
cw := cbg.NewCborWriter(w)
1756
-
fieldCount := 6
1756
+
fieldCount := 7
1757
1757
1758
1758
if t.AddedAt == nil {
1759
1759
fieldCount--
1760
1760
}
1761
1761
1762
1762
if t.Description == nil {
1763
+
fieldCount--
1764
+
}
1765
+
1766
+
if t.Source == nil {
1763
1767
fieldCount--
1764
1768
}
1765
1769
···
1855
1859
return err
1856
1860
}
1857
1861
1862
+
// t.Source (string) (string)
1863
+
if t.Source != nil {
1864
+
1865
+
if len("source") > 1000000 {
1866
+
return xerrors.Errorf("Value in field \"source\" was too long")
1867
+
}
1868
+
1869
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
1870
+
return err
1871
+
}
1872
+
if _, err := cw.WriteString(string("source")); err != nil {
1873
+
return err
1874
+
}
1875
+
1876
+
if t.Source == nil {
1877
+
if _, err := cw.Write(cbg.CborNull); err != nil {
1878
+
return err
1879
+
}
1880
+
} else {
1881
+
if len(*t.Source) > 1000000 {
1882
+
return xerrors.Errorf("Value in field t.Source was too long")
1883
+
}
1884
+
1885
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil {
1886
+
return err
1887
+
}
1888
+
if _, err := cw.WriteString(string(*t.Source)); err != nil {
1889
+
return err
1890
+
}
1891
+
}
1892
+
}
1893
+
1858
1894
// t.AddedAt (string) (string)
1859
1895
if t.AddedAt != nil {
1860
1896
···
2006
2042
2007
2043
t.Owner = string(sval)
2008
2044
}
2045
+
// t.Source (string) (string)
2046
+
case "source":
2047
+
2048
+
{
2049
+
b, err := cr.ReadByte()
2050
+
if err != nil {
2051
+
return err
2052
+
}
2053
+
if b != cbg.CborNull[0] {
2054
+
if err := cr.UnreadByte(); err != nil {
2055
+
return err
2056
+
}
2057
+
2058
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2059
+
if err != nil {
2060
+
return err
2061
+
}
2062
+
2063
+
t.Source = (*string)(&sval)
2064
+
}
2065
+
}
2009
2066
// t.AddedAt (string) (string)
2010
2067
case "addedAt":
2011
2068
···
2076
2133
fieldCount--
2077
2134
}
2078
2135
2079
-
if t.SourceRepo == nil {
2136
+
if t.Source == nil {
2080
2137
fieldCount--
2081
2138
}
2082
2139
···
2203
2260
}
2204
2261
}
2205
2262
2206
-
// t.CreatedAt (string) (string)
2207
-
if t.CreatedAt != nil {
2263
+
// t.Source (tangled.RepoPull_Source) (struct)
2264
+
if t.Source != nil {
2208
2265
2209
-
if len("createdAt") > 1000000 {
2210
-
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2266
+
if len("source") > 1000000 {
2267
+
return xerrors.Errorf("Value in field \"source\" was too long")
2211
2268
}
2212
2269
2213
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2270
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
2214
2271
return err
2215
2272
}
2216
-
if _, err := cw.WriteString(string("createdAt")); err != nil {
2273
+
if _, err := cw.WriteString(string("source")); err != nil {
2217
2274
return err
2218
2275
}
2219
2276
2220
-
if t.CreatedAt == nil {
2221
-
if _, err := cw.Write(cbg.CborNull); err != nil {
2222
-
return err
2223
-
}
2224
-
} else {
2225
-
if len(*t.CreatedAt) > 1000000 {
2226
-
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2227
-
}
2228
-
2229
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
2230
-
return err
2231
-
}
2232
-
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
2233
-
return err
2234
-
}
2277
+
if err := t.Source.MarshalCBOR(cw); err != nil {
2278
+
return err
2235
2279
}
2236
2280
}
2237
2281
2238
-
// t.SourceRepo (string) (string)
2239
-
if t.SourceRepo != nil {
2282
+
// t.CreatedAt (string) (string)
2283
+
if t.CreatedAt != nil {
2240
2284
2241
-
if len("sourceRepo") > 1000000 {
2242
-
return xerrors.Errorf("Value in field \"sourceRepo\" was too long")
2285
+
if len("createdAt") > 1000000 {
2286
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
2243
2287
}
2244
2288
2245
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil {
2289
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
2246
2290
return err
2247
2291
}
2248
-
if _, err := cw.WriteString(string("sourceRepo")); err != nil {
2292
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
2249
2293
return err
2250
2294
}
2251
2295
2252
-
if t.SourceRepo == nil {
2296
+
if t.CreatedAt == nil {
2253
2297
if _, err := cw.Write(cbg.CborNull); err != nil {
2254
2298
return err
2255
2299
}
2256
2300
} else {
2257
-
if len(*t.SourceRepo) > 1000000 {
2258
-
return xerrors.Errorf("Value in field t.SourceRepo was too long")
2301
+
if len(*t.CreatedAt) > 1000000 {
2302
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
2259
2303
}
2260
2304
2261
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil {
2305
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil {
2262
2306
return err
2263
2307
}
2264
-
if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil {
2308
+
if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil {
2265
2309
return err
2266
2310
}
2267
2311
}
···
2436
2480
2437
2481
t.PullId = int64(extraI)
2438
2482
}
2439
-
// t.CreatedAt (string) (string)
2440
-
case "createdAt":
2483
+
// t.Source (tangled.RepoPull_Source) (struct)
2484
+
case "source":
2441
2485
2442
2486
{
2487
+
2443
2488
b, err := cr.ReadByte()
2444
2489
if err != nil {
2445
2490
return err
···
2448
2493
if err := cr.UnreadByte(); err != nil {
2449
2494
return err
2450
2495
}
2451
-
2452
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2453
-
if err != nil {
2454
-
return err
2496
+
t.Source = new(RepoPull_Source)
2497
+
if err := t.Source.UnmarshalCBOR(cr); err != nil {
2498
+
return xerrors.Errorf("unmarshaling t.Source pointer: %w", err)
2455
2499
}
2456
-
2457
-
t.CreatedAt = (*string)(&sval)
2458
2500
}
2501
+
2459
2502
}
2460
-
// t.SourceRepo (string) (string)
2461
-
case "sourceRepo":
2503
+
// t.CreatedAt (string) (string)
2504
+
case "createdAt":
2462
2505
2463
2506
{
2464
2507
b, err := cr.ReadByte()
···
2475
2518
return err
2476
2519
}
2477
2520
2478
-
t.SourceRepo = (*string)(&sval)
2521
+
t.CreatedAt = (*string)(&sval)
2479
2522
}
2480
2523
}
2481
2524
// t.TargetRepo (string) (string)
···
2499
2542
}
2500
2543
2501
2544
t.TargetBranch = string(sval)
2545
+
}
2546
+
2547
+
default:
2548
+
// Field doesn't exist on this type, so ignore it
2549
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
2550
+
return err
2551
+
}
2552
+
}
2553
+
}
2554
+
2555
+
return nil
2556
+
}
2557
+
func (t *RepoPull_Source) MarshalCBOR(w io.Writer) error {
2558
+
if t == nil {
2559
+
_, err := w.Write(cbg.CborNull)
2560
+
return err
2561
+
}
2562
+
2563
+
cw := cbg.NewCborWriter(w)
2564
+
fieldCount := 2
2565
+
2566
+
if t.Repo == nil {
2567
+
fieldCount--
2568
+
}
2569
+
2570
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
2571
+
return err
2572
+
}
2573
+
2574
+
// t.Repo (string) (string)
2575
+
if t.Repo != nil {
2576
+
2577
+
if len("repo") > 1000000 {
2578
+
return xerrors.Errorf("Value in field \"repo\" was too long")
2579
+
}
2580
+
2581
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
2582
+
return err
2583
+
}
2584
+
if _, err := cw.WriteString(string("repo")); err != nil {
2585
+
return err
2586
+
}
2587
+
2588
+
if t.Repo == nil {
2589
+
if _, err := cw.Write(cbg.CborNull); err != nil {
2590
+
return err
2591
+
}
2592
+
} else {
2593
+
if len(*t.Repo) > 1000000 {
2594
+
return xerrors.Errorf("Value in field t.Repo was too long")
2595
+
}
2596
+
2597
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil {
2598
+
return err
2599
+
}
2600
+
if _, err := cw.WriteString(string(*t.Repo)); err != nil {
2601
+
return err
2602
+
}
2603
+
}
2604
+
}
2605
+
2606
+
// t.Branch (string) (string)
2607
+
if len("branch") > 1000000 {
2608
+
return xerrors.Errorf("Value in field \"branch\" was too long")
2609
+
}
2610
+
2611
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil {
2612
+
return err
2613
+
}
2614
+
if _, err := cw.WriteString(string("branch")); err != nil {
2615
+
return err
2616
+
}
2617
+
2618
+
if len(t.Branch) > 1000000 {
2619
+
return xerrors.Errorf("Value in field t.Branch was too long")
2620
+
}
2621
+
2622
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil {
2623
+
return err
2624
+
}
2625
+
if _, err := cw.WriteString(string(t.Branch)); err != nil {
2626
+
return err
2627
+
}
2628
+
return nil
2629
+
}
2630
+
2631
+
func (t *RepoPull_Source) UnmarshalCBOR(r io.Reader) (err error) {
2632
+
*t = RepoPull_Source{}
2633
+
2634
+
cr := cbg.NewCborReader(r)
2635
+
2636
+
maj, extra, err := cr.ReadHeader()
2637
+
if err != nil {
2638
+
return err
2639
+
}
2640
+
defer func() {
2641
+
if err == io.EOF {
2642
+
err = io.ErrUnexpectedEOF
2643
+
}
2644
+
}()
2645
+
2646
+
if maj != cbg.MajMap {
2647
+
return fmt.Errorf("cbor input should be of type map")
2648
+
}
2649
+
2650
+
if extra > cbg.MaxLength {
2651
+
return fmt.Errorf("RepoPull_Source: map struct too large (%d)", extra)
2652
+
}
2653
+
2654
+
n := extra
2655
+
2656
+
nameBuf := make([]byte, 6)
2657
+
for i := uint64(0); i < n; i++ {
2658
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
2659
+
if err != nil {
2660
+
return err
2661
+
}
2662
+
2663
+
if !ok {
2664
+
// Field doesn't exist on this type, so ignore it
2665
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
2666
+
return err
2667
+
}
2668
+
continue
2669
+
}
2670
+
2671
+
switch string(nameBuf[:nameLen]) {
2672
+
// t.Repo (string) (string)
2673
+
case "repo":
2674
+
2675
+
{
2676
+
b, err := cr.ReadByte()
2677
+
if err != nil {
2678
+
return err
2679
+
}
2680
+
if b != cbg.CborNull[0] {
2681
+
if err := cr.UnreadByte(); err != nil {
2682
+
return err
2683
+
}
2684
+
2685
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2686
+
if err != nil {
2687
+
return err
2688
+
}
2689
+
2690
+
t.Repo = (*string)(&sval)
2691
+
}
2692
+
}
2693
+
// t.Branch (string) (string)
2694
+
case "branch":
2695
+
2696
+
{
2697
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
2698
+
if err != nil {
2699
+
return err
2700
+
}
2701
+
2702
+
t.Branch = string(sval)
2502
2703
}
2503
2704
2504
2705
default:
+15
-9
api/tangled/repopull.go
+15
-9
api/tangled/repopull.go
···
17
17
} //
18
18
// RECORDTYPE: RepoPull
19
19
type RepoPull struct {
20
-
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
-
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
-
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
23
-
Patch string `json:"patch" cborgen:"patch"`
24
-
PullId int64 `json:"pullId" cborgen:"pullId"`
25
-
SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"`
26
-
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
-
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
28
-
Title string `json:"title" cborgen:"title"`
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"`
21
+
Body *string `json:"body,omitempty" cborgen:"body,omitempty"`
22
+
CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"`
23
+
Patch string `json:"patch" cborgen:"patch"`
24
+
PullId int64 `json:"pullId" cborgen:"pullId"`
25
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
26
+
TargetBranch string `json:"targetBranch" cborgen:"targetBranch"`
27
+
TargetRepo string `json:"targetRepo" cborgen:"targetRepo"`
28
+
Title string `json:"title" cborgen:"title"`
29
+
}
30
+
31
+
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
32
+
type RepoPull_Source struct {
33
+
Branch string `json:"branch" cborgen:"branch"`
34
+
Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
29
35
}
+2
api/tangled/tangledrepo.go
+2
api/tangled/tangledrepo.go
+16
-1
appview/db/db.go
+16
-1
appview/db/db.go
···
257
257
})
258
258
259
259
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
260
-
// add unconstrained column
261
260
_, err := tx.Exec(`
262
261
alter table comments add column deleted text; -- timestamp
263
262
alter table comments add column edited text; -- timestamp
263
+
`)
264
+
return err
265
+
})
266
+
267
+
runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error {
268
+
_, err := tx.Exec(`
269
+
alter table pulls add column source_branch text;
270
+
alter table pulls add column source_repo_at text;
271
+
alter table pull_submissions add column source_rev text;
272
+
`)
273
+
return err
274
+
})
275
+
276
+
runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
277
+
_, err := tx.Exec(`
278
+
alter table repos add column source text;
264
279
`)
265
280
return err
266
281
})
+83
-38
appview/db/issues.go
+83
-38
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 {
···
12
13
OwnerDid string
13
14
IssueId int
14
15
IssueAt string
15
-
Created *time.Time
16
+
Created time.Time
16
17
Title string
17
18
Body string
18
19
Open bool
20
+
21
+
// optionally, populate this when querying for reverse mappings
22
+
// like comment counts, parent repo etc.
19
23
Metadata *IssueMetadata
20
24
}
21
25
22
26
type IssueMetadata struct {
23
27
CommentCount int
28
+
Repo *Repo
24
29
// labels, assignee etc.
25
30
}
26
31
···
98
103
return ownerDid, err
99
104
}
100
105
101
-
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) {
102
107
var issues []Issue
103
108
openValue := 0
104
109
if isOpen {
···
106
111
}
107
112
108
113
rows, err := e.Query(
109
-
`select
110
-
i.owner_did,
111
-
i.issue_id,
112
-
i.created,
113
-
i.title,
114
-
i.body,
115
-
i.open,
116
-
count(c.id)
117
-
from
118
-
issues i
119
-
left join
120
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
121
-
where
122
-
i.repo_at = ? and i.open = ?
123
-
group by
124
-
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
125
-
order by
126
-
i.created desc`,
127
-
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)
128
147
if err != nil {
129
148
return nil, err
130
149
}
···
143
162
if err != nil {
144
163
return nil, err
145
164
}
146
-
issue.Created = &createdTime
165
+
issue.Created = createdTime
147
166
issue.Metadata = &metadata
148
167
149
168
issues = append(issues, issue)
···
156
175
return issues, nil
157
176
}
158
177
159
-
func GetIssuesByOwnerDid(e Execer, ownerDid string) ([]Issue, error) {
178
+
// timeframe here is directly passed into the sql query filter, and any
179
+
// timeframe in the past should be negative; e.g.: "-3 months"
180
+
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
160
181
var issues []Issue
161
182
162
183
rows, err := e.Query(
···
168
189
i.title,
169
190
i.body,
170
191
i.open,
171
-
count(c.id)
192
+
r.did,
193
+
r.name,
194
+
r.knot,
195
+
r.rkey,
196
+
r.created
172
197
from
173
198
issues i
174
-
left join
175
-
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
199
+
join
200
+
repos r on i.repo_at = r.at_uri
176
201
where
177
-
i.owner_did = ?
178
-
group by
179
-
i.id, i.owner_did, i.repo_at, i.issue_id, i.created, i.title, i.body, i.open
202
+
i.owner_did = ? and i.created >= date ('now', ?)
180
203
order by
181
204
i.created desc`,
182
-
ownerDid)
205
+
ownerDid, timeframe)
183
206
if err != nil {
184
207
return nil, err
185
208
}
···
187
210
188
211
for rows.Next() {
189
212
var issue Issue
190
-
var createdAt string
191
-
var metadata IssueMetadata
192
-
err := rows.Scan(&issue.OwnerDid, &issue.RepoAt, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount)
213
+
var issueCreatedAt, repoCreatedAt string
214
+
var repo Repo
215
+
err := rows.Scan(
216
+
&issue.OwnerDid,
217
+
&issue.RepoAt,
218
+
&issue.IssueId,
219
+
&issueCreatedAt,
220
+
&issue.Title,
221
+
&issue.Body,
222
+
&issue.Open,
223
+
&repo.Did,
224
+
&repo.Name,
225
+
&repo.Knot,
226
+
&repo.Rkey,
227
+
&repoCreatedAt,
228
+
)
193
229
if err != nil {
194
230
return nil, err
195
231
}
196
232
197
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
233
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
198
234
if err != nil {
199
235
return nil, err
200
236
}
201
-
issue.Created = &createdTime
202
-
issue.Metadata = &metadata
237
+
issue.Created = issueCreatedTime
238
+
239
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
240
+
if err != nil {
241
+
return nil, err
242
+
}
243
+
repo.Created = repoCreatedTime
244
+
245
+
issue.Metadata = &IssueMetadata{
246
+
Repo: &repo,
247
+
}
203
248
204
249
issues = append(issues, issue)
205
250
}
···
226
271
if err != nil {
227
272
return nil, err
228
273
}
229
-
issue.Created = &createdTime
274
+
issue.Created = createdTime
230
275
231
276
return &issue, nil
232
277
}
···
246
291
if err != nil {
247
292
return nil, nil, err
248
293
}
249
-
issue.Created = &createdTime
294
+
issue.Created = createdTime
250
295
251
296
comments, err := GetComments(e, repoAt, issueId)
252
297
if err != nil {
+129
-45
appview/db/profile.go
+129
-45
appview/db/profile.go
···
1
1
package db
2
2
3
3
import (
4
-
"sort"
4
+
"fmt"
5
5
"time"
6
6
)
7
7
8
-
type ProfileTimelineEvent struct {
9
-
EventAt time.Time
10
-
Type string
11
-
*Issue
12
-
*Pull
13
-
*Repo
8
+
type RepoEvent struct {
9
+
Repo *Repo
10
+
Source *Repo
11
+
}
12
+
13
+
type ProfileTimeline struct {
14
+
ByMonth []ByMonth
15
+
}
16
+
17
+
type ByMonth struct {
18
+
RepoEvents []RepoEvent
19
+
IssueEvents IssueEvents
20
+
PullEvents PullEvents
21
+
}
22
+
23
+
func (b ByMonth) IsEmpty() bool {
24
+
return len(b.RepoEvents) == 0 &&
25
+
len(b.IssueEvents.Items) == 0 &&
26
+
len(b.PullEvents.Items) == 0
27
+
}
28
+
29
+
type IssueEvents struct {
30
+
Items []*Issue
31
+
}
32
+
33
+
type IssueEventStats struct {
34
+
Open int
35
+
Closed int
36
+
}
37
+
38
+
func (i IssueEvents) Stats() IssueEventStats {
39
+
var open, closed int
40
+
for _, issue := range i.Items {
41
+
if issue.Open {
42
+
open += 1
43
+
} else {
44
+
closed += 1
45
+
}
46
+
}
47
+
48
+
return IssueEventStats{
49
+
Open: open,
50
+
Closed: closed,
51
+
}
52
+
}
53
+
54
+
type PullEvents struct {
55
+
Items []*Pull
56
+
}
57
+
58
+
func (p PullEvents) Stats() PullEventStats {
59
+
var open, merged, closed int
60
+
for _, pull := range p.Items {
61
+
switch pull.State {
62
+
case PullOpen:
63
+
open += 1
64
+
case PullMerged:
65
+
merged += 1
66
+
case PullClosed:
67
+
closed += 1
68
+
}
69
+
}
70
+
71
+
return PullEventStats{
72
+
Open: open,
73
+
Merged: merged,
74
+
Closed: closed,
75
+
}
76
+
}
77
+
78
+
type PullEventStats struct {
79
+
Closed int
80
+
Open int
81
+
Merged int
14
82
}
15
83
16
-
func MakeProfileTimeline(e Execer, forDid string) ([]ProfileTimelineEvent, error) {
17
-
timeline := []ProfileTimelineEvent{}
18
-
limit := 30
84
+
const TimeframeMonths = 3
19
85
20
-
pulls, err := GetPullsByOwnerDid(e, forDid)
86
+
func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) {
87
+
timeline := ProfileTimeline{
88
+
ByMonth: make([]ByMonth, TimeframeMonths),
89
+
}
90
+
currentMonth := time.Now().Month()
91
+
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
92
+
93
+
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
21
94
if err != nil {
22
-
return timeline, err
95
+
return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
23
96
}
24
97
98
+
// group pulls by month
25
99
for _, pull := range pulls {
26
-
repo, err := GetRepoByAtUri(e, string(pull.RepoAt))
27
-
if err != nil {
28
-
return timeline, err
100
+
pullMonth := pull.Created.Month()
101
+
102
+
if currentMonth-pullMonth > TimeframeMonths {
103
+
// shouldn't happen; but times are weird
104
+
continue
29
105
}
30
106
31
-
timeline = append(timeline, ProfileTimelineEvent{
32
-
EventAt: pull.Created,
33
-
Type: "pull",
34
-
Pull: &pull,
35
-
Repo: repo,
36
-
})
107
+
idx := currentMonth - pullMonth
108
+
items := &timeline.ByMonth[idx].PullEvents.Items
109
+
110
+
*items = append(*items, &pull)
37
111
}
38
112
39
-
issues, err := GetIssuesByOwnerDid(e, forDid)
113
+
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
40
114
if err != nil {
41
-
return timeline, err
115
+
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
42
116
}
43
117
44
118
for _, issue := range issues {
45
-
repo, err := GetRepoByAtUri(e, string(issue.RepoAt))
46
-
if err != nil {
47
-
return timeline, err
119
+
issueMonth := issue.Created.Month()
120
+
121
+
if currentMonth-issueMonth > TimeframeMonths {
122
+
// shouldn't happen; but times are weird
123
+
continue
48
124
}
49
125
50
-
timeline = append(timeline, ProfileTimelineEvent{
51
-
EventAt: *issue.Created,
52
-
Type: "issue",
53
-
Issue: &issue,
54
-
Repo: repo,
55
-
})
126
+
idx := currentMonth - issueMonth
127
+
items := &timeline.ByMonth[idx].IssueEvents.Items
128
+
129
+
*items = append(*items, &issue)
56
130
}
57
131
58
132
repos, err := GetAllReposByDid(e, forDid)
59
133
if err != nil {
60
-
return timeline, err
134
+
return nil, fmt.Errorf("error getting all repos by did: %w", err)
61
135
}
62
136
63
137
for _, repo := range repos {
64
-
timeline = append(timeline, ProfileTimelineEvent{
65
-
EventAt: repo.Created,
66
-
Type: "repo",
67
-
Repo: &repo,
68
-
})
69
-
}
138
+
// TODO: get this in the original query; requires COALESCE because nullable
139
+
var sourceRepo *Repo
140
+
if repo.Source != "" {
141
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
142
+
if err != nil {
143
+
return nil, err
144
+
}
145
+
}
146
+
147
+
repoMonth := repo.Created.Month()
148
+
149
+
if currentMonth-repoMonth > TimeframeMonths {
150
+
// shouldn't happen; but times are weird
151
+
continue
152
+
}
70
153
71
-
sort.Slice(timeline, func(i, j int) bool {
72
-
return timeline[i].EventAt.After(timeline[j].EventAt)
73
-
})
154
+
idx := currentMonth - repoMonth
74
155
75
-
if len(timeline) > limit {
76
-
timeline = timeline[:limit]
156
+
items := &timeline.ByMonth[idx].RepoEvents
157
+
*items = append(*items, RepoEvent{
158
+
Repo: &repo,
159
+
Source: sourceRepo,
160
+
})
77
161
}
78
162
79
-
return timeline, nil
163
+
return &timeline, nil
80
164
}
+302
-38
appview/db/pulls.go
+302
-38
appview/db/pulls.go
···
4
4
"database/sql"
5
5
"fmt"
6
6
"log"
7
+
"sort"
7
8
"strings"
8
9
"time"
9
10
10
11
"github.com/bluekeyes/go-gitdiff/gitdiff"
11
12
"github.com/bluesky-social/indigo/atproto/syntax"
13
+
"tangled.sh/tangled.sh/core/patchutil"
12
14
"tangled.sh/tangled.sh/core/types"
13
15
)
14
16
···
62
64
Submissions []*PullSubmission
63
65
64
66
// meta
65
-
Created time.Time
67
+
Created time.Time
68
+
PullSource *PullSource
69
+
70
+
// optionally, populate this when querying for reverse mappings
71
+
Repo *Repo
72
+
}
73
+
74
+
type PullSource struct {
75
+
Branch string
76
+
RepoAt *syntax.ATURI
77
+
78
+
// optionally populate this for reverse mappings
79
+
Repo *Repo
66
80
}
67
81
68
82
type PullSubmission struct {
···
77
91
RoundNumber int
78
92
Patch string
79
93
Comments []PullComment
94
+
SourceRev string // include the rev that was used to create this submission: only for branch PRs
80
95
81
96
// meta
82
97
Created time.Time
···
109
124
return len(p.Submissions) - 1
110
125
}
111
126
112
-
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
127
+
func (p *Pull) IsPatchBased() bool {
128
+
return p.PullSource == nil
129
+
}
130
+
131
+
func (p *Pull) IsBranchBased() bool {
132
+
if p.PullSource != nil {
133
+
if p.PullSource.RepoAt != nil {
134
+
return p.PullSource.RepoAt == &p.RepoAt
135
+
} else {
136
+
// no repo specified
137
+
return true
138
+
}
139
+
}
140
+
return false
141
+
}
142
+
143
+
func (p *Pull) IsForkBased() bool {
144
+
if p.PullSource != nil {
145
+
if p.PullSource.RepoAt != nil {
146
+
// make sure repos are different
147
+
return p.PullSource.RepoAt != &p.RepoAt
148
+
}
149
+
}
150
+
return false
151
+
}
152
+
153
+
func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) {
113
154
patch := s.Patch
114
155
115
-
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)
116
182
if err != nil {
117
183
log.Println(err)
118
184
}
···
150
216
return nd
151
217
}
152
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
231
+
}
232
+
153
233
func NewPull(tx *sql.Tx, pull *Pull) error {
154
234
defer tx.Rollback()
155
235
···
175
255
pull.PullId = nextId
176
256
pull.State = PullOpen
177
257
178
-
_, err = tx.Exec(`
179
-
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state)
180
-
values (?, ?, ?, ?, ?, ?, ?, ?)
181
-
`, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Rkey, pull.State)
258
+
var sourceBranch, sourceRepoAt *string
259
+
if pull.PullSource != nil {
260
+
sourceBranch = &pull.PullSource.Branch
261
+
if pull.PullSource.RepoAt != nil {
262
+
x := pull.PullSource.RepoAt.String()
263
+
sourceRepoAt = &x
264
+
}
265
+
}
266
+
267
+
_, err = tx.Exec(
268
+
`
269
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
270
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
271
+
pull.RepoAt,
272
+
pull.OwnerDid,
273
+
pull.PullId,
274
+
pull.Title,
275
+
pull.TargetBranch,
276
+
pull.Body,
277
+
pull.Rkey,
278
+
pull.State,
279
+
sourceBranch,
280
+
sourceRepoAt,
281
+
)
182
282
if err != nil {
183
283
return err
184
284
}
185
285
186
286
_, err = tx.Exec(`
187
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
188
-
values (?, ?, ?, ?)
189
-
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch)
287
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
288
+
values (?, ?, ?, ?, ?)
289
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
190
290
if err != nil {
191
291
return err
192
292
}
···
215
315
return pullId - 1, err
216
316
}
217
317
218
-
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) {
219
-
var pulls []Pull
318
+
func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) {
319
+
pulls := make(map[int]*Pull)
220
320
221
321
rows, err := e.Query(`
222
322
select
···
228
328
target_branch,
229
329
pull_at,
230
330
body,
231
-
rkey
331
+
rkey,
332
+
source_branch,
333
+
source_repo_at
232
334
from
233
335
pulls
234
336
where
235
-
repo_at = ? and state = ?
236
-
order by
237
-
created desc`, repoAt, state)
337
+
repo_at = ? and state = ?`, repoAt, state)
238
338
if err != nil {
239
339
return nil, err
240
340
}
···
243
343
for rows.Next() {
244
344
var pull Pull
245
345
var createdAt string
346
+
var sourceBranch, sourceRepoAt sql.NullString
246
347
err := rows.Scan(
247
348
&pull.OwnerDid,
248
349
&pull.PullId,
···
253
354
&pull.PullAt,
254
355
&pull.Body,
255
356
&pull.Rkey,
357
+
&sourceBranch,
358
+
&sourceRepoAt,
256
359
)
257
360
if err != nil {
258
361
return nil, err
···
264
367
}
265
368
pull.Created = createdTime
266
369
267
-
pulls = append(pulls, pull)
370
+
if sourceBranch.Valid {
371
+
pull.PullSource = &PullSource{
372
+
Branch: sourceBranch.String,
373
+
}
374
+
if sourceRepoAt.Valid {
375
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
376
+
if err != nil {
377
+
return nil, err
378
+
}
379
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
380
+
}
381
+
}
382
+
383
+
pulls[pull.PullId] = &pull
268
384
}
269
385
386
+
// get latest round no. for each pull
387
+
inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
388
+
submissionsQuery := fmt.Sprintf(`
389
+
select
390
+
id, pull_id, round_number
391
+
from
392
+
pull_submissions
393
+
where
394
+
repo_at = ? and pull_id in (%s)
395
+
`, inClause)
396
+
397
+
args := make([]any, len(pulls)+1)
398
+
args[0] = repoAt.String()
399
+
idx := 1
400
+
for _, p := range pulls {
401
+
args[idx] = p.PullId
402
+
idx += 1
403
+
}
404
+
submissionsRows, err := e.Query(submissionsQuery, args...)
405
+
if err != nil {
406
+
return nil, err
407
+
}
408
+
defer submissionsRows.Close()
409
+
410
+
for submissionsRows.Next() {
411
+
var s PullSubmission
412
+
err := submissionsRows.Scan(
413
+
&s.ID,
414
+
&s.PullId,
415
+
&s.RoundNumber,
416
+
)
417
+
if err != nil {
418
+
return nil, err
419
+
}
420
+
421
+
if p, ok := pulls[s.PullId]; ok {
422
+
p.Submissions = make([]*PullSubmission, s.RoundNumber+1)
423
+
p.Submissions[s.RoundNumber] = &s
424
+
}
425
+
}
270
426
if err := rows.Err(); err != nil {
271
427
return nil, err
272
428
}
273
429
274
-
return pulls, nil
430
+
// get comment count on latest submission on each pull
431
+
inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ")
432
+
commentsQuery := fmt.Sprintf(`
433
+
select
434
+
count(id), pull_id
435
+
from
436
+
pull_comments
437
+
where
438
+
submission_id in (%s)
439
+
group by
440
+
submission_id
441
+
`, inClause)
442
+
443
+
args = []any{}
444
+
for _, p := range pulls {
445
+
args = append(args, p.Submissions[p.LastRoundNumber()].ID)
446
+
}
447
+
commentsRows, err := e.Query(commentsQuery, args...)
448
+
if err != nil {
449
+
return nil, err
450
+
}
451
+
defer commentsRows.Close()
452
+
453
+
for commentsRows.Next() {
454
+
var commentCount, pullId int
455
+
err := commentsRows.Scan(
456
+
&commentCount,
457
+
&pullId,
458
+
)
459
+
if err != nil {
460
+
return nil, err
461
+
}
462
+
if p, ok := pulls[pullId]; ok {
463
+
p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount)
464
+
}
465
+
}
466
+
if err := rows.Err(); err != nil {
467
+
return nil, err
468
+
}
469
+
470
+
orderedByDate := []*Pull{}
471
+
for _, p := range pulls {
472
+
orderedByDate = append(orderedByDate, p)
473
+
}
474
+
sort.Slice(orderedByDate, func(i, j int) bool {
475
+
return orderedByDate[i].Created.After(orderedByDate[j].Created)
476
+
})
477
+
478
+
return orderedByDate, nil
275
479
}
276
480
277
481
func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
···
286
490
pull_at,
287
491
repo_at,
288
492
body,
289
-
rkey
493
+
rkey,
494
+
source_branch,
495
+
source_repo_at
290
496
from
291
497
pulls
292
498
where
···
296
502
297
503
var pull Pull
298
504
var createdAt string
505
+
var sourceBranch, sourceRepoAt sql.NullString
299
506
err := row.Scan(
300
507
&pull.OwnerDid,
301
508
&pull.PullId,
···
307
514
&pull.RepoAt,
308
515
&pull.Body,
309
516
&pull.Rkey,
517
+
&sourceBranch,
518
+
&sourceRepoAt,
310
519
)
311
520
if err != nil {
312
521
return nil, err
···
318
527
}
319
528
pull.Created = createdTime
320
529
530
+
// populate source
531
+
if sourceBranch.Valid {
532
+
pull.PullSource = &PullSource{
533
+
Branch: sourceBranch.String,
534
+
}
535
+
if sourceRepoAt.Valid {
536
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
537
+
if err != nil {
538
+
return nil, err
539
+
}
540
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
541
+
}
542
+
}
543
+
321
544
submissionsQuery := `
322
545
select
323
-
id, pull_id, repo_at, round_number, patch, created
546
+
id, pull_id, repo_at, round_number, patch, created, source_rev
324
547
from
325
548
pull_submissions
326
549
where
···
337
560
for submissionsRows.Next() {
338
561
var submission PullSubmission
339
562
var submissionCreatedStr string
563
+
var submissionSourceRev sql.NullString
340
564
err := submissionsRows.Scan(
341
565
&submission.ID,
342
566
&submission.PullId,
···
344
568
&submission.RoundNumber,
345
569
&submission.Patch,
346
570
&submissionCreatedStr,
571
+
&submissionSourceRev,
347
572
)
348
573
if err != nil {
349
574
return nil, err
···
354
579
return nil, err
355
580
}
356
581
submission.Created = submissionCreatedTime
582
+
583
+
if submissionSourceRev.Valid {
584
+
submission.SourceRev = submissionSourceRev.String
585
+
}
357
586
358
587
submissionsMap[submission.ID] = &submission
359
588
}
···
425
654
return nil, err
426
655
}
427
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
+
}
667
+
}
668
+
428
669
pull.Submissions = make([]*PullSubmission, len(submissionsMap))
429
670
for _, submission := range submissionsMap {
430
671
pull.Submissions[submission.RoundNumber] = submission
···
433
674
return &pull, nil
434
675
}
435
676
436
-
func GetPullsByOwnerDid(e Execer, did string) ([]Pull, error) {
677
+
// timeframe here is directly passed into the sql query filter, and any
678
+
// timeframe in the past should be negative; e.g.: "-3 months"
679
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
437
680
var pulls []Pull
438
681
439
682
rows, err := e.Query(`
440
683
select
441
-
owner_did,
442
-
repo_at,
443
-
pull_id,
444
-
created,
445
-
title,
446
-
state
684
+
p.owner_did,
685
+
p.repo_at,
686
+
p.pull_id,
687
+
p.created,
688
+
p.title,
689
+
p.state,
690
+
r.did,
691
+
r.name,
692
+
r.knot,
693
+
r.rkey,
694
+
r.created
447
695
from
448
-
pulls
696
+
pulls p
697
+
join
698
+
repos r on p.repo_at = r.at_uri
449
699
where
450
-
owner_did = ?
700
+
p.owner_did = ? and p.created >= date ('now', ?)
451
701
order by
452
-
created desc`, did)
702
+
p.created desc`, did, timeframe)
453
703
if err != nil {
454
704
return nil, err
455
705
}
···
457
707
458
708
for rows.Next() {
459
709
var pull Pull
460
-
var createdAt string
710
+
var repo Repo
711
+
var pullCreatedAt, repoCreatedAt string
461
712
err := rows.Scan(
462
713
&pull.OwnerDid,
463
714
&pull.RepoAt,
464
715
&pull.PullId,
465
-
&createdAt,
716
+
&pullCreatedAt,
466
717
&pull.Title,
467
718
&pull.State,
719
+
&repo.Did,
720
+
&repo.Name,
721
+
&repo.Knot,
722
+
&repo.Rkey,
723
+
&repoCreatedAt,
468
724
)
469
725
if err != nil {
470
726
return nil, err
471
727
}
472
728
473
-
createdTime, err := time.Parse(time.RFC3339, createdAt)
729
+
pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
730
+
if err != nil {
731
+
return nil, err
732
+
}
733
+
pull.Created = pullCreatedTime
734
+
735
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
474
736
if err != nil {
475
737
return nil, err
476
738
}
477
-
pull.Created = createdTime
739
+
repo.Created = repoCreatedTime
740
+
741
+
pull.Repo = &repo
478
742
479
743
pulls = append(pulls, pull)
480
744
}
···
529
793
return err
530
794
}
531
795
532
-
func ResubmitPull(e Execer, pull *Pull, newPatch string) error {
796
+
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
533
797
newRoundNumber := len(pull.Submissions)
534
798
_, err := e.Exec(`
535
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
536
-
values (?, ?, ?, ?)
537
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch)
799
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
800
+
values (?, ?, ?, ?, ?)
801
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
538
802
539
803
return err
540
804
}
+129
-15
appview/db/repos.go
+129
-15
appview/db/repos.go
···
3
3
import (
4
4
"database/sql"
5
5
"time"
6
+
7
+
"github.com/bluesky-social/indigo/atproto/syntax"
6
8
)
7
9
8
10
type Repo struct {
···
16
18
17
19
// optionally, populate this when querying for reverse mappings
18
20
RepoStats *RepoStats
21
+
22
+
// optional
23
+
Source string
19
24
}
20
25
21
26
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
22
27
var repos []Repo
23
28
24
29
rows, err := e.Query(
25
-
`select did, name, knot, rkey, description, created
30
+
`select did, name, knot, rkey, description, created, source
26
31
from repos
27
32
order by created desc
28
33
limit ?
···
37
42
for rows.Next() {
38
43
var repo Repo
39
44
err := scanRepo(
40
-
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created,
45
+
rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
41
46
)
42
47
if err != nil {
43
48
return nil, err
···
63
68
r.rkey,
64
69
r.description,
65
70
r.created,
66
-
count(s.id) as star_count
71
+
count(s.id) as star_count,
72
+
r.source
67
73
from
68
74
repos r
69
75
left join
···
71
77
where
72
78
r.did = ?
73
79
group by
74
-
r.at_uri`, did)
80
+
r.at_uri
81
+
order by r.created desc`,
82
+
did)
75
83
if err != nil {
76
84
return nil, err
77
85
}
···
82
90
var repoStats RepoStats
83
91
var createdAt string
84
92
var nullableDescription sql.NullString
93
+
var nullableSource sql.NullString
85
94
86
-
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount)
95
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource)
87
96
if err != nil {
88
97
return nil, err
89
98
}
90
99
91
100
if nullableDescription.Valid {
92
101
repo.Description = nullableDescription.String
93
-
} else {
94
-
repo.Description = ""
102
+
}
103
+
104
+
if nullableSource.Valid {
105
+
repo.Source = nullableSource.String
95
106
}
96
107
97
108
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
···
159
170
160
171
func AddRepo(e Execer, repo *Repo) error {
161
172
_, err := e.Exec(
162
-
`insert into repos
163
-
(did, name, knot, rkey, at_uri, description)
164
-
values (?, ?, ?, ?, ?, ?)`,
165
-
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description,
173
+
`insert into repos
174
+
(did, name, knot, rkey, at_uri, description, source)
175
+
values (?, ?, ?, ?, ?, ?, ?)`,
176
+
repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
166
177
)
167
178
return err
168
179
}
169
180
170
-
func RemoveRepo(e Execer, did, name, rkey string) error {
171
-
_, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey)
181
+
func RemoveRepo(e Execer, did, name string) error {
182
+
_, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name)
172
183
return err
173
184
}
174
185
186
+
func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) {
187
+
var nullableSource sql.NullString
188
+
err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource)
189
+
if err != nil {
190
+
return "", err
191
+
}
192
+
return nullableSource.String, nil
193
+
}
194
+
195
+
func GetForksByDid(e Execer, did string) ([]Repo, error) {
196
+
var repos []Repo
197
+
198
+
rows, err := e.Query(
199
+
`select did, name, knot, rkey, description, created, at_uri, source
200
+
from repos
201
+
where did = ? and source is not null and source != ''
202
+
order by created desc`,
203
+
did,
204
+
)
205
+
if err != nil {
206
+
return nil, err
207
+
}
208
+
defer rows.Close()
209
+
210
+
for rows.Next() {
211
+
var repo Repo
212
+
var createdAt string
213
+
var nullableDescription sql.NullString
214
+
var nullableSource sql.NullString
215
+
216
+
err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
217
+
if err != nil {
218
+
return nil, err
219
+
}
220
+
221
+
if nullableDescription.Valid {
222
+
repo.Description = nullableDescription.String
223
+
}
224
+
225
+
if nullableSource.Valid {
226
+
repo.Source = nullableSource.String
227
+
}
228
+
229
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
230
+
if err != nil {
231
+
repo.Created = time.Now()
232
+
} else {
233
+
repo.Created = createdAtTime
234
+
}
235
+
236
+
repos = append(repos, repo)
237
+
}
238
+
239
+
if err := rows.Err(); err != nil {
240
+
return nil, err
241
+
}
242
+
243
+
return repos, nil
244
+
}
245
+
246
+
func GetForkByDid(e Execer, did string, name string) (*Repo, error) {
247
+
var repo Repo
248
+
var createdAt string
249
+
var nullableDescription sql.NullString
250
+
var nullableSource sql.NullString
251
+
252
+
row := e.QueryRow(
253
+
`select did, name, knot, rkey, description, created, at_uri, source
254
+
from repos
255
+
where did = ? and name = ? and source is not null and source != ''`,
256
+
did, name,
257
+
)
258
+
259
+
err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource)
260
+
if err != nil {
261
+
return nil, err
262
+
}
263
+
264
+
if nullableDescription.Valid {
265
+
repo.Description = nullableDescription.String
266
+
}
267
+
268
+
if nullableSource.Valid {
269
+
repo.Source = nullableSource.String
270
+
}
271
+
272
+
createdAtTime, err := time.Parse(time.RFC3339, createdAt)
273
+
if err != nil {
274
+
repo.Created = time.Now()
275
+
} else {
276
+
repo.Created = createdAtTime
277
+
}
278
+
279
+
return &repo, nil
280
+
}
281
+
175
282
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
176
283
_, err := e.Exec(
177
284
`insert into collaborators (did, repo)
···
249
356
PullCount PullCount
250
357
}
251
358
252
-
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error {
359
+
func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
253
360
var createdAt string
254
361
var nullableDescription sql.NullString
255
-
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil {
362
+
var nullableSource sql.NullString
363
+
if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
256
364
return err
257
365
}
258
366
···
267
375
*created = time.Now()
268
376
} else {
269
377
*created = createdAtTime
378
+
}
379
+
380
+
if nullableSource.Valid {
381
+
*source = nullableSource.String
382
+
} else {
383
+
*source = ""
270
384
}
271
385
272
386
return nil
+13
appview/db/timeline.go
+13
appview/db/timeline.go
···
9
9
*Repo
10
10
*Follow
11
11
*Star
12
+
12
13
EventAt time.Time
14
+
15
+
// optional: populate only if Repo is a fork
16
+
Source *Repo
13
17
}
14
18
15
19
// TODO: this gathers heterogenous events from different sources and aggregates
···
34
38
}
35
39
36
40
for _, repo := range repos {
41
+
var sourceRepo *Repo
42
+
if repo.Source != "" {
43
+
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
44
+
if err != nil {
45
+
return nil, err
46
+
}
47
+
}
48
+
37
49
events = append(events, TimelineEvent{
38
50
Repo: &repo,
39
51
EventAt: repo.Created,
52
+
Source: sourceRepo,
40
53
})
41
54
}
42
55
+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
-
}
+9
-1
appview/pages/funcmap.go
+9
-1
appview/pages/funcmap.go
···
13
13
"time"
14
14
15
15
"github.com/dustin/go-humanize"
16
+
"tangled.sh/tangled.sh/core/appview/filetree"
17
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
16
18
)
17
19
18
20
func funcMap() template.FuncMap {
···
30
32
return strings.Split(s, sep)
31
33
},
32
34
"add": func(a, b int) int {
35
+
return a + b
36
+
},
37
+
// the absolute state of go templates
38
+
"add64": func(a, b int64) int64 {
33
39
return a + b
34
40
},
35
41
"sub": func(a, b int) int {
···
137
143
return v.Slice(start, end).Interface()
138
144
},
139
145
"markdown": func(text string) template.HTML {
140
-
return template.HTML(renderMarkdown(text))
146
+
return template.HTML(markup.RenderMarkdown(text))
141
147
},
142
148
"isNil": func(t any) bool {
143
149
// returns false for other "zero" values
···
168
174
}
169
175
return template.HTML(data)
170
176
},
177
+
"cssContentHash": CssContentHash,
178
+
"fileTree": filetree.FileTree,
171
179
}
172
180
}
173
181
-23
appview/pages/markdown.go
-23
appview/pages/markdown.go
···
1
-
package pages
2
-
3
-
import (
4
-
"bytes"
5
-
6
-
"github.com/yuin/goldmark"
7
-
"github.com/yuin/goldmark/extension"
8
-
"github.com/yuin/goldmark/parser"
9
-
)
10
-
11
-
func renderMarkdown(source string) string {
12
-
md := goldmark.New(
13
-
goldmark.WithExtensions(extension.GFM),
14
-
goldmark.WithParserOptions(
15
-
parser.WithAutoHeadingID(),
16
-
),
17
-
)
18
-
var buf bytes.Buffer
19
-
if err := md.Convert([]byte(source), &buf); err != nil {
20
-
return source
21
-
}
22
-
return buf.String()
23
-
}
+24
appview/pages/markup/markdown.go
+24
appview/pages/markup/markdown.go
···
1
+
// Package markup is an umbrella package for all markups and their renderers.
2
+
package markup
3
+
4
+
import (
5
+
"bytes"
6
+
7
+
"github.com/yuin/goldmark"
8
+
"github.com/yuin/goldmark/extension"
9
+
"github.com/yuin/goldmark/parser"
10
+
)
11
+
12
+
func RenderMarkdown(source string) string {
13
+
md := goldmark.New(
14
+
goldmark.WithExtensions(extension.GFM),
15
+
goldmark.WithParserOptions(
16
+
parser.WithAutoHeadingID(),
17
+
),
18
+
)
19
+
var buf bytes.Buffer
20
+
if err := md.Convert([]byte(source), &buf); err != nil {
21
+
return source
22
+
}
23
+
return buf.String()
24
+
}
+26
appview/pages/markup/readme.go
+26
appview/pages/markup/readme.go
···
1
+
package markup
2
+
3
+
import "strings"
4
+
5
+
type Format string
6
+
7
+
const (
8
+
FormatMarkdown Format = "markdown"
9
+
FormatText Format = "text"
10
+
)
11
+
12
+
var FileTypes map[Format][]string = map[Format][]string{
13
+
FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"},
14
+
}
15
+
16
+
func GetFormat(filename string) Format {
17
+
for format, extensions := range FileTypes {
18
+
for _, extension := range extensions {
19
+
if strings.HasSuffix(filename, extension) {
20
+
return format
21
+
}
22
+
}
23
+
}
24
+
// default format
25
+
return FormatText
26
+
}
+360
-104
appview/pages/pages.go
+360
-104
appview/pages/pages.go
···
2
2
3
3
import (
4
4
"bytes"
5
+
"crypto/sha256"
5
6
"embed"
7
+
"encoding/hex"
6
8
"fmt"
7
9
"html/template"
8
10
"io"
9
11
"io/fs"
10
12
"log"
11
13
"net/http"
14
+
"os"
12
15
"path"
13
16
"path/filepath"
14
17
"slices"
15
18
"strings"
16
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
+
17
28
"github.com/alecthomas/chroma/v2"
18
29
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
19
30
"github.com/alecthomas/chroma/v2/lexers"
20
31
"github.com/alecthomas/chroma/v2/styles"
21
32
"github.com/bluesky-social/indigo/atproto/syntax"
22
33
"github.com/microcosm-cc/bluemonday"
23
-
"tangled.sh/tangled.sh/core/appview/auth"
24
-
"tangled.sh/tangled.sh/core/appview/db"
25
-
"tangled.sh/tangled.sh/core/appview/state/userutil"
26
-
"tangled.sh/tangled.sh/core/types"
27
34
)
28
35
29
36
//go:embed templates/* static
30
37
var Files embed.FS
31
38
32
39
type Pages struct {
33
-
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
34
44
}
35
45
36
-
func NewPages() *Pages {
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
+
}
59
+
60
+
func (p *Pages) loadAllTemplates() {
37
61
templates := make(map[string]*template.Template)
62
+
var fragmentPaths []string
38
63
39
-
// Walk through embedded templates directory and parse all .html files
40
-
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
64
+
// Use embedded FS for initial loading
65
+
// First, collect all fragment paths
66
+
err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
41
67
if err != nil {
42
68
return err
43
69
}
44
-
45
-
if !d.IsDir() && strings.HasSuffix(path, ".html") {
46
-
name := strings.TrimPrefix(path, "templates/")
47
-
name = strings.TrimSuffix(name, ".html")
70
+
if d.IsDir() {
71
+
return nil
72
+
}
73
+
if !strings.HasSuffix(path, ".html") {
74
+
return nil
75
+
}
76
+
if !strings.Contains(path, "fragments/") {
77
+
return nil
78
+
}
79
+
name := strings.TrimPrefix(path, "templates/")
80
+
name = strings.TrimSuffix(name, ".html")
81
+
tmpl, err := template.New(name).
82
+
Funcs(funcMap()).
83
+
ParseFS(p.embedFS, path)
84
+
if err != nil {
85
+
log.Fatalf("setting up fragment: %v", err)
86
+
}
87
+
templates[name] = tmpl
88
+
fragmentPaths = append(fragmentPaths, path)
89
+
log.Printf("loaded fragment: %s", name)
90
+
return nil
91
+
})
92
+
if err != nil {
93
+
log.Fatalf("walking template dir for fragments: %v", err)
94
+
}
48
95
49
-
// add fragments as templates
50
-
if strings.HasPrefix(path, "templates/fragments/") {
51
-
tmpl, err := template.New(name).
52
-
Funcs(funcMap()).
53
-
ParseFS(Files, path)
54
-
if err != nil {
55
-
return fmt.Errorf("setting up fragment: %w", err)
56
-
}
96
+
// Then walk through and setup the rest of the templates
97
+
err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
98
+
if err != nil {
99
+
return err
100
+
}
101
+
if d.IsDir() {
102
+
return nil
103
+
}
104
+
if !strings.HasSuffix(path, "html") {
105
+
return nil
106
+
}
107
+
// Skip fragments as they've already been loaded
108
+
if strings.Contains(path, "fragments/") {
109
+
return nil
110
+
}
111
+
// Skip layouts
112
+
if strings.Contains(path, "layouts/") {
113
+
return nil
114
+
}
115
+
name := strings.TrimPrefix(path, "templates/")
116
+
name = strings.TrimSuffix(name, ".html")
117
+
// Add the page template on top of the base
118
+
allPaths := []string{}
119
+
allPaths = append(allPaths, "templates/layouts/*.html")
120
+
allPaths = append(allPaths, fragmentPaths...)
121
+
allPaths = append(allPaths, path)
122
+
tmpl, err := template.New(name).
123
+
Funcs(funcMap()).
124
+
ParseFS(p.embedFS, allPaths...)
125
+
if err != nil {
126
+
return fmt.Errorf("setting up template: %w", err)
127
+
}
128
+
templates[name] = tmpl
129
+
log.Printf("loaded template: %s", name)
130
+
return nil
131
+
})
132
+
if err != nil {
133
+
log.Fatalf("walking template dir: %v", err)
134
+
}
57
135
58
-
templates[name] = tmpl
59
-
log.Printf("loaded fragment: %s", name)
60
-
}
136
+
log.Printf("total templates loaded: %d", len(templates))
137
+
p.t = templates
138
+
}
61
139
62
-
// layouts and fragments are applied first
63
-
if !strings.HasPrefix(path, "templates/layouts/") &&
64
-
!strings.HasPrefix(path, "templates/fragments/") {
65
-
// Add the page template on top of the base
66
-
tmpl, err := template.New(name).
67
-
Funcs(funcMap()).
68
-
ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path)
69
-
if err != nil {
70
-
return fmt.Errorf("setting up template: %w", err)
71
-
}
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
+
}
72
145
73
-
templates[name] = tmpl
74
-
log.Printf("loaded template: %s", name)
75
-
}
146
+
log.Printf("reloading template from disk: %s", name)
76
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") {
77
158
return nil
78
159
}
160
+
if !strings.Contains(path, "fragments/") {
161
+
return nil
162
+
}
163
+
fragmentPaths = append(fragmentPaths, path)
79
164
return nil
80
165
})
81
166
if err != nil {
82
-
log.Fatalf("walking template dir: %v", err)
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)
83
174
}
84
175
85
-
log.Printf("total templates loaded: %d", len(templates))
176
+
// Create a new template
177
+
tmpl := template.New(name).Funcs(funcMap())
86
178
87
-
return &Pages{
88
-
t: templates,
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)
89
184
}
185
+
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)
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
90
200
}
91
201
92
-
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
+
}
93
221
}
94
222
95
223
func (p *Pages) execute(name string, w io.Writer, params any) error {
96
-
return p.t[name].ExecuteTemplate(w, "layouts/base", params)
224
+
return p.executeOrReload(name, w, "layouts/base", params)
97
225
}
98
226
99
227
func (p *Pages) executePlain(name string, w io.Writer, params any) error {
100
-
return p.t[name].Execute(w, params)
228
+
return p.executeOrReload(name, w, "", params)
101
229
}
102
230
103
231
func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
104
-
return p.t[name].ExecuteTemplate(w, "layouts/repobase", params)
232
+
return p.executeOrReload(name, w, "layouts/repobase", params)
233
+
}
234
+
235
+
type LoginParams struct {
105
236
}
106
237
107
238
func (p *Pages) Login(w io.Writer, params LoginParams) error {
···
158
289
return p.execute("repo/new", w, params)
159
290
}
160
291
292
+
type ForkRepoParams struct {
293
+
LoggedInUser *auth.User
294
+
Knots []string
295
+
RepoInfo RepoInfo
296
+
}
297
+
298
+
func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
299
+
return p.execute("repo/fork", w, params)
300
+
}
301
+
161
302
type ProfilePageParams struct {
162
303
LoggedInUser *auth.User
163
304
UserDid string
···
166
307
CollaboratingRepos []db.Repo
167
308
ProfileStats ProfileStats
168
309
FollowStatus db.FollowStatus
169
-
DidHandleMap map[string]string
170
310
AvatarUri string
171
-
ProfileTimeline []db.ProfileTimelineEvent
311
+
ProfileTimeline *db.ProfileTimeline
312
+
313
+
DidHandleMap map[string]string
172
314
}
173
315
174
316
type ProfileStats struct {
···
186
328
}
187
329
188
330
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
189
-
return p.executePlain("fragments/follow", w, params)
331
+
return p.executePlain("user/fragments/follow", w, params)
190
332
}
191
333
192
-
type StarFragmentParams struct {
334
+
type RepoActionsFragmentParams struct {
193
335
IsStarred bool
194
336
RepoAt syntax.ATURI
195
337
Stats db.RepoStats
196
338
}
197
339
198
-
func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
199
-
return p.executePlain("fragments/star", w, params)
340
+
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
341
+
return p.executePlain("repo/fragments/repoActions", w, params)
200
342
}
201
343
202
344
type RepoDescriptionParams struct {
···
204
346
}
205
347
206
348
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
207
-
return p.executePlain("fragments/editRepoDescription", w, params)
349
+
return p.executePlain("repo/fragments/editRepoDescription", w, params)
208
350
}
209
351
210
352
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
211
-
return p.executePlain("fragments/repoDescription", w, params)
353
+
return p.executePlain("repo/fragments/repoDescription", w, params)
212
354
}
213
355
214
356
type RepoInfo struct {
215
-
Name string
216
-
OwnerDid string
217
-
OwnerHandle string
218
-
Description string
219
-
Knot string
220
-
RepoAt syntax.ATURI
221
-
IsStarred bool
222
-
Stats db.RepoStats
223
-
Roles RolesInRepo
357
+
Name string
358
+
OwnerDid string
359
+
OwnerHandle string
360
+
Description string
361
+
Knot string
362
+
RepoAt syntax.ATURI
363
+
IsStarred bool
364
+
Stats db.RepoStats
365
+
Roles RolesInRepo
366
+
Source *db.Repo
367
+
SourceHandle string
368
+
DisableFork bool
224
369
}
225
370
226
371
type RolesInRepo struct {
···
229
374
230
375
func (r RolesInRepo) SettingsAllowed() bool {
231
376
return slices.Contains(r.Roles, "repo:settings")
377
+
}
378
+
379
+
func (r RolesInRepo) CollaboratorInviteAllowed() bool {
380
+
return slices.Contains(r.Roles, "repo:invite")
381
+
}
382
+
383
+
func (r RolesInRepo) RepoDeleteAllowed() bool {
384
+
return slices.Contains(r.Roles, "repo:delete")
232
385
}
233
386
234
387
func (r RolesInRepo) IsOwner() bool {
···
269
422
270
423
func (r RepoInfo) GetTabs() [][]string {
271
424
tabs := [][]string{
272
-
{"overview", "/"},
273
-
{"issues", "/issues"},
274
-
{"pulls", "/pulls"},
425
+
{"overview", "/", "square-chart-gantt"},
426
+
{"issues", "/issues", "circle-dot"},
427
+
{"pulls", "/pulls", "git-pull-request"},
275
428
}
276
429
277
430
if r.Roles.SettingsAllowed() {
278
-
tabs = append(tabs, []string{"settings", "/settings"})
431
+
tabs = append(tabs, []string{"settings", "/settings", "cog"})
279
432
}
280
433
281
434
return tabs
···
326
479
ext := filepath.Ext(params.ReadmeFileName)
327
480
switch ext {
328
481
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
329
-
htmlString = renderMarkdown(params.Readme)
482
+
htmlString = markup.RenderMarkdown(params.Readme)
330
483
params.Raw = false
331
484
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
332
485
default:
···
353
506
}
354
507
355
508
type RepoCommitParams struct {
356
-
LoggedInUser *auth.User
357
-
RepoInfo RepoInfo
358
-
Active string
359
-
types.RepoCommitResponse
509
+
LoggedInUser *auth.User
510
+
RepoInfo RepoInfo
511
+
Active string
360
512
EmailToDidOrHandle map[string]string
513
+
514
+
types.RepoCommitResponse
361
515
}
362
516
363
517
func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
···
422
576
}
423
577
424
578
type RepoBlobParams struct {
425
-
LoggedInUser *auth.User
426
-
RepoInfo RepoInfo
427
-
Active string
428
-
BreadCrumbs [][]string
579
+
LoggedInUser *auth.User
580
+
RepoInfo RepoInfo
581
+
Active string
582
+
BreadCrumbs [][]string
583
+
ShowRendered bool
584
+
RenderToggle bool
585
+
RenderedContents template.HTML
429
586
types.RepoBlobResponse
430
587
}
431
588
432
589
func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
433
-
style := styles.Get("bw")
434
-
b := style.Builder()
435
-
b.Add(chroma.LiteralString, "noitalic")
436
-
style, _ = b.Build()
590
+
var style *chroma.Style = styles.Get("catpuccin-latte")
591
+
592
+
if params.ShowRendered {
593
+
switch markup.GetFormat(params.Path) {
594
+
case markup.FormatMarkdown:
595
+
params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
596
+
}
597
+
}
437
598
438
599
if params.Lines < 5000 {
439
600
c := params.Contents
···
442
603
chromahtml.WithLineNumbers(true),
443
604
chromahtml.WithLinkableLineNumbers(true, "L"),
444
605
chromahtml.Standalone(false),
606
+
chromahtml.WithClasses(true),
445
607
)
446
608
447
609
lexer := lexers.Get(filepath.Base(params.Path))
···
478
640
RepoInfo RepoInfo
479
641
Collaborators []Collaborator
480
642
Active string
643
+
Branches []string
644
+
DefaultBranch string
481
645
// TODO: use repoinfo.roles
482
646
IsCollaboratorInviteAllowed bool
483
647
}
···
488
652
}
489
653
490
654
type RepoIssuesParams struct {
491
-
LoggedInUser *auth.User
492
-
RepoInfo RepoInfo
493
-
Active string
494
-
Issues []db.Issue
495
-
DidHandleMap map[string]string
496
-
655
+
LoggedInUser *auth.User
656
+
RepoInfo RepoInfo
657
+
Active string
658
+
Issues []db.Issue
659
+
DidHandleMap map[string]string
660
+
Page pagination.Page
497
661
FilteringByOpen bool
498
662
}
499
663
···
543
707
}
544
708
545
709
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
546
-
return p.executePlain("fragments/editIssueComment", w, params)
710
+
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
547
711
}
548
712
549
713
type SingleIssueCommentParams struct {
···
555
719
}
556
720
557
721
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
558
-
return p.executePlain("fragments/issueComment", w, params)
722
+
return p.executePlain("repo/issues/fragments/issueComment", w, params)
559
723
}
560
724
561
725
type RepoNewPullParams struct {
···
573
737
type RepoPullsParams struct {
574
738
LoggedInUser *auth.User
575
739
RepoInfo RepoInfo
576
-
Pulls []db.Pull
740
+
Pulls []*db.Pull
577
741
Active string
578
742
DidHandleMap map[string]string
579
743
FilteringBy db.PullState
···
584
748
return p.executeRepo("repo/pulls/pulls", w, params)
585
749
}
586
750
587
-
type RepoSinglePullParams struct {
588
-
LoggedInUser *auth.User
589
-
RepoInfo RepoInfo
590
-
Active string
591
-
DidHandleMap map[string]string
751
+
type ResubmitResult uint64
592
752
593
-
Pull db.Pull
594
-
MergeCheck types.MergeCheckResponse
753
+
const (
754
+
ShouldResubmit ResubmitResult = iota
755
+
ShouldNotResubmit
756
+
Unknown
757
+
)
758
+
759
+
func (r ResubmitResult) Yes() bool {
760
+
return r == ShouldResubmit
761
+
}
762
+
func (r ResubmitResult) No() bool {
763
+
return r == ShouldNotResubmit
764
+
}
765
+
func (r ResubmitResult) Unknown() bool {
766
+
return r == Unknown
767
+
}
768
+
769
+
type RepoSinglePullParams struct {
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
595
777
}
596
778
597
779
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
604
786
DidHandleMap map[string]string
605
787
RepoInfo RepoInfo
606
788
Pull *db.Pull
607
-
Diff types.NiceDiff
789
+
Diff *types.NiceDiff
608
790
Round int
609
791
Submission *db.PullSubmission
610
792
}
···
614
796
return p.execute("repo/pulls/patch", w, params)
615
797
}
616
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)
811
+
}
812
+
813
+
type PullPatchUploadParams struct {
814
+
RepoInfo RepoInfo
815
+
}
816
+
817
+
func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
818
+
return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
819
+
}
820
+
821
+
type PullCompareBranchesParams struct {
822
+
RepoInfo RepoInfo
823
+
Branches []types.Branch
824
+
}
825
+
826
+
func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
827
+
return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
828
+
}
829
+
830
+
type PullCompareForkParams struct {
831
+
RepoInfo RepoInfo
832
+
Forks []db.Repo
833
+
}
834
+
835
+
func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
836
+
return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
837
+
}
838
+
839
+
type PullCompareForkBranchesParams struct {
840
+
RepoInfo RepoInfo
841
+
SourceBranches []types.Branch
842
+
TargetBranches []types.Branch
843
+
}
844
+
845
+
func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
846
+
return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
847
+
}
848
+
617
849
type PullResubmitParams struct {
618
850
LoggedInUser *auth.User
619
851
RepoInfo RepoInfo
···
622
854
}
623
855
624
856
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
625
-
return p.executePlain("fragments/pullResubmit", w, params)
857
+
return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
626
858
}
627
859
628
860
type PullActionsParams struct {
629
-
LoggedInUser *auth.User
630
-
RepoInfo RepoInfo
631
-
Pull *db.Pull
632
-
RoundNumber int
633
-
MergeCheck types.MergeCheckResponse
861
+
LoggedInUser *auth.User
862
+
RepoInfo RepoInfo
863
+
Pull *db.Pull
864
+
RoundNumber int
865
+
MergeCheck types.MergeCheckResponse
866
+
ResubmitCheck ResubmitResult
634
867
}
635
868
636
869
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
637
-
return p.executePlain("fragments/pullActions", w, params)
870
+
return p.executePlain("repo/pulls/fragments/pullActions", w, params)
638
871
}
639
872
640
873
type PullNewCommentParams struct {
···
645
878
}
646
879
647
880
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
648
-
return p.executePlain("fragments/pullNewComment", w, params)
881
+
return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
649
882
}
650
883
651
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
+
652
889
sub, err := fs.Sub(Files, "static")
653
890
if err != nil {
654
891
log.Fatalf("no static dir found? that's crazy: %v", err)
···
659
896
660
897
func Cache(h http.Handler) http.Handler {
661
898
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
662
-
if strings.HasSuffix(r.URL.Path, ".css") {
899
+
path := strings.Split(r.URL.Path, "?")[0]
900
+
901
+
if strings.HasSuffix(path, ".css") {
663
902
// on day for css files
664
903
w.Header().Set("Cache-Control", "public, max-age=86400")
665
904
} else {
···
667
906
}
668
907
h.ServeHTTP(w, r)
669
908
})
909
+
}
910
+
911
+
func CssContentHash() string {
912
+
cssFile, err := Files.Open("static/tw.css")
913
+
if err != nil {
914
+
log.Printf("Error opening CSS file: %v", err)
915
+
return ""
916
+
}
917
+
defer cssFile.Close()
918
+
919
+
hasher := sha256.New()
920
+
if _, err := io.Copy(hasher, cssFile); err != nil {
921
+
log.Printf("Error hashing CSS file: %v", err)
922
+
return ""
923
+
}
924
+
925
+
return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
670
926
}
671
927
672
928
func (p *Pages) Error500(w io.Writer) error {
-33
appview/pages/templates/fragments/cloneInstructions.html
-33
appview/pages/templates/fragments/cloneInstructions.html
···
1
-
{{ define "fragments/cloneInstructions" }}
2
-
<section class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4">
3
-
<div class="flex flex-col gap-2">
4
-
<strong>push</strong>
5
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
6
-
<code class="dark:text-gray-100">git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
7
-
</div>
8
-
</div>
9
-
10
-
<div class="flex flex-col gap-2">
11
-
<strong>clone</strong>
12
-
<div class="md:pl-4 flex flex-col gap-2">
13
-
14
-
<div class="flex items-center gap-3">
15
-
<span class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white">HTTP</span>
16
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
17
-
<code class="dark:text-gray-100">git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
18
-
</div>
19
-
</div>
20
-
21
-
<div class="flex items-center gap-3">
22
-
<span class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white">SSH</span>
23
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
24
-
<code class="dark:text-gray-100">git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
25
-
</div>
26
-
</div>
27
-
</div>
28
-
</div>
29
-
30
-
31
-
<p class="py-2 text-gray-500 dark:text-gray-400">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
32
-
</section>
33
-
{{ end }}
-117
appview/pages/templates/fragments/diff.html
-117
appview/pages/templates/fragments/diff.html
···
1
-
{{ define "fragments/diff" }}
2
-
{{ $repo := index . 0 }}
3
-
{{ $diff := index . 1 }}
4
-
{{ $commit := $diff.Commit }}
5
-
{{ $stat := $diff.Stat }}
6
-
{{ $diff := $diff.Diff }}
7
-
8
-
{{ $this := $commit.This }}
9
-
{{ $parent := $commit.Parent }}
10
-
11
-
{{ $last := sub (len $diff) 1 }}
12
-
{{ range $idx, $hunk := $diff }}
13
-
{{ with $hunk }}
14
-
<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">
15
-
<div id="file-{{ .Name.New }}">
16
-
<div id="diff-file">
17
-
<details open>
18
-
<summary class="list-none cursor-pointer sticky top-0">
19
-
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
20
-
<div id="left-side-items" class="p-2 flex gap-2 items-center">
21
-
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
22
-
23
-
{{ if .IsNew }}
24
-
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
25
-
{{ else if .IsDelete }}
26
-
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
27
-
{{ else if .IsCopy }}
28
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
29
-
{{ else if .IsRename }}
30
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
31
-
{{ else }}
32
-
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
33
-
{{ end }}
34
-
35
-
{{ if .IsDelete }}
36
-
<a class="dark:text-white" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
37
-
{{ .Name.Old }}
38
-
</a>
39
-
{{ else if (or .IsCopy .IsRename) }}
40
-
<a class="dark:text-white" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
41
-
{{ .Name.Old }}
42
-
</a>
43
-
{{ i "arrow-right" "w-4 h-4" }}
44
-
<a class="dark:text-white" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
45
-
{{ .Name.New }}
46
-
</a>
47
-
{{ else }}
48
-
<a class="dark:text-white" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
49
-
{{ .Name.New }}
50
-
</a>
51
-
{{ end }}
52
-
</div>
53
-
54
-
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
55
-
<div id="right-side-items" class="p-2 flex items-center">
56
-
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
57
-
{{ if gt $idx 0 }}
58
-
{{ $prev := index $diff (sub $idx 1) }}
59
-
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
60
-
{{ end }}
61
-
62
-
{{ if lt $idx $last }}
63
-
{{ $next := index $diff (add $idx 1) }}
64
-
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
65
-
{{ end }}
66
-
</div>
67
-
68
-
</div>
69
-
</summary>
70
-
71
-
<div class="transition-all duration-700 ease-in-out">
72
-
{{ if .IsDelete }}
73
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
74
-
This file has been deleted in this commit.
75
-
</p>
76
-
{{ else }}
77
-
{{ if .IsBinary }}
78
-
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
79
-
This is a binary file and will not be displayed.
80
-
</p>
81
-
{{ else }}
82
-
<pre class="overflow-x-auto">
83
-
{{- range .TextFragments -}}
84
-
<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none">{{- .Header -}}</div><div class="overflow-x-auto"><div class="min-w-full inline-block">
85
-
{{- range .Lines -}}
86
-
{{- if eq .Op.String "+" -}}
87
-
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full">
88
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
89
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
90
-
</div>
91
-
{{- end -}}
92
-
{{- if eq .Op.String "-" -}}
93
-
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full">
94
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
95
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
96
-
</div>
97
-
{{- end -}}
98
-
{{- if eq .Op.String " " -}}
99
-
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full">
100
-
<div class="w-10 flex-shrink-0 select-none p-1 text-center">{{ .Op.String }}</div>
101
-
<div class="p-1 whitespace-pre">{{ .Line }}</div>
102
-
</div>
103
-
{{- end -}}
104
-
{{- end -}}</div></div>{{- end -}}
105
-
</pre>
106
-
{{- end -}}
107
-
{{ end }}
108
-
</div>
109
-
110
-
</details>
111
-
112
-
</div>
113
-
</div>
114
-
</section>
115
-
{{ end }}
116
-
{{ end }}
117
-
{{ end }}
-52
appview/pages/templates/fragments/editIssueComment.html
-52
appview/pages/templates/fragments/editIssueComment.html
···
1
-
{{ define "fragments/editIssueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
5
-
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
-
8
-
<!-- show user "hats" -->
9
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
-
{{ if $isIssueAuthor }}
11
-
<span class="before:content-['ยท']"></span>
12
-
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
13
-
author
14
-
</span>
15
-
{{ end }}
16
-
17
-
<span class="before:content-['ยท']"></span>
18
-
<a
19
-
href="#{{ .CommentId }}"
20
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
21
-
id="{{ .CommentId }}">
22
-
{{ .Created | timeFmt }}
23
-
</a>
24
-
25
-
<button
26
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
27
-
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
28
-
hx-include="#edit-textarea-{{ .CommentId }}"
29
-
hx-target="#comment-container-{{ .CommentId }}"
30
-
hx-swap="outerHTML">
31
-
{{ i "check" "w-4 h-4" }}
32
-
</button>
33
-
<button
34
-
class="btn px-2 py-1 flex items-center gap-2 text-sm"
35
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
36
-
hx-target="#comment-container-{{ .CommentId }}"
37
-
hx-swap="outerHTML">
38
-
{{ i "x" "w-4 h-4" }}
39
-
</button>
40
-
<span id="comment-{{.CommentId}}-status"></span>
41
-
</div>
42
-
43
-
<div>
44
-
<textarea
45
-
id="edit-textarea-{{ .CommentId }}"
46
-
name="body"
47
-
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
48
-
</div>
49
-
</div>
50
-
{{ end }}
51
-
{{ end }}
52
-
-11
appview/pages/templates/fragments/editRepoDescription.html
-11
appview/pages/templates/fragments/editRepoDescription.html
···
1
-
{{ define "fragments/editRepoDescription" }}
2
-
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
-
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
-
<button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm">
5
-
{{ i "check" "w-3 h-3" }} save
6
-
</button>
7
-
<button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
-
{{ i "x" "w-3 h-3" }} cancel
9
-
</button>
10
-
</form>
11
-
{{ end }}
-17
appview/pages/templates/fragments/follow.html
-17
appview/pages/templates/fragments/follow.html
···
1
-
{{ define "fragments/follow" }}
2
-
<button id="followBtn"
3
-
class="btn mt-2 w-full"
4
-
5
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
-
hx-post="/follow?subject={{.UserDid}}"
7
-
{{ else }}
8
-
hx-delete="/follow?subject={{.UserDid}}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="#followBtn"
13
-
hx-swap="outerHTML"
14
-
>
15
-
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
-
</button>
17
-
{{ end }}
-60
appview/pages/templates/fragments/issueComment.html
-60
appview/pages/templates/fragments/issueComment.html
···
1
-
{{ define "fragments/issueComment" }}
2
-
{{ with .Comment }}
3
-
<div id="comment-container-{{.CommentId}}">
4
-
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
5
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
-
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
-
8
-
<!-- show user "hats" -->
9
-
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
-
{{ if $isIssueAuthor }}
11
-
<span class="before:content-['ยท']"></span>
12
-
<span class="rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
13
-
author
14
-
</span>
15
-
{{ end }}
16
-
17
-
<span class="before:content-['ยท']"></span>
18
-
<a
19
-
href="#{{ .CommentId }}"
20
-
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
21
-
id="{{ .CommentId }}">
22
-
{{ if .Deleted }}
23
-
deleted {{ .Deleted | timeFmt }}
24
-
{{ else if .Edited }}
25
-
edited {{ .Edited | timeFmt }}
26
-
{{ else }}
27
-
{{ .Created | timeFmt }}
28
-
{{ end }}
29
-
</a>
30
-
31
-
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
32
-
{{ if and $isCommentOwner (not .Deleted) }}
33
-
<button
34
-
class="btn px-2 py-1 text-sm"
35
-
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
36
-
hx-swap="outerHTML"
37
-
hx-target="#comment-container-{{.CommentId}}"
38
-
>
39
-
{{ i "pencil" "w-4 h-4" }}
40
-
</button>
41
-
<button
42
-
class="btn px-2 py-1 text-sm text-red-500"
43
-
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
44
-
hx-confirm="Are you sure you want to delete your comment?"
45
-
hx-swap="outerHTML"
46
-
hx-target="#comment-container-{{.CommentId}}"
47
-
>
48
-
{{ i "trash-2" "w-4 h-4" }}
49
-
</button>
50
-
{{ end }}
51
-
52
-
</div>
53
-
{{ if not .Deleted }}
54
-
<div class="prose dark:prose-invert">
55
-
{{ .Body | markdown }}
56
-
</div>
57
-
{{ end }}
58
-
</div>
59
-
{{ end }}
60
-
{{ end }}
-72
appview/pages/templates/fragments/pullActions.html
-72
appview/pages/templates/fragments/pullActions.html
···
1
-
{{ define "fragments/pullActions" }}
2
-
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
3
-
{{ $roundNumber := .RoundNumber }}
4
-
5
-
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
6
-
{{ $isMerged := .Pull.State.IsMerged }}
7
-
{{ $isClosed := .Pull.State.IsClosed }}
8
-
{{ $isOpen := .Pull.State.IsOpen }}
9
-
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
10
-
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
11
-
{{ $isLastRound := eq $roundNumber $lastIdx }}
12
-
<div class="relative w-fit">
13
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
14
-
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
15
-
<button
16
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
17
-
hx-target="#actions-{{$roundNumber}}"
18
-
hx-swap="outerHtml"
19
-
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
20
-
{{ i "message-square-plus" "w-4 h-4" }}
21
-
<span>comment</span>
22
-
</button>
23
-
{{ if and $isPushAllowed $isOpen $isLastRound }}
24
-
{{ $disabled := "" }}
25
-
{{ if $isConflicted }}
26
-
{{ $disabled = "disabled" }}
27
-
{{ end }}
28
-
<button
29
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
30
-
hx-swap="none"
31
-
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
32
-
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
33
-
{{ i "git-merge" "w-4 h-4" }}
34
-
<span>merge</span>
35
-
</button>
36
-
{{ end }}
37
-
38
-
{{ if and $isPullAuthor $isOpen $isLastRound }}
39
-
<button
40
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
41
-
hx-target="#actions-{{$roundNumber}}"
42
-
hx-swap="outerHtml"
43
-
class="btn p-2 flex items-center gap-2">
44
-
{{ i "rotate-ccw" "w-4 h-4" }}
45
-
<span>resubmit</span>
46
-
</button>
47
-
{{ end }}
48
-
49
-
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
50
-
<button
51
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
52
-
hx-swap="none"
53
-
class="btn p-2 flex items-center gap-2">
54
-
{{ i "ban" "w-4 h-4" }}
55
-
<span>close</span>
56
-
</button>
57
-
{{ end }}
58
-
59
-
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
60
-
<button
61
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
62
-
hx-swap="none"
63
-
class="btn p-2 flex items-center gap-2">
64
-
{{ i "circle-dot" "w-4 h-4" }}
65
-
<span>reopen</span>
66
-
</button>
67
-
{{ end }}
68
-
</div>
69
-
</div>
70
-
{{ end }}
71
-
72
-
-32
appview/pages/templates/fragments/pullNewComment.html
-32
appview/pages/templates/fragments/pullNewComment.html
···
1
-
{{ define "fragments/pullNewComment" }}
2
-
<div
3
-
id="pull-comment-card-{{ .RoundNumber }}"
4
-
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
-
<div class="text-sm text-gray-500 dark:text-gray-400">
6
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
-
</div>
8
-
<form
9
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
10
-
hx-swap="none"
11
-
class="w-full flex flex-wrap gap-2">
12
-
<textarea
13
-
name="body"
14
-
class="w-full p-2 rounded border border-gray-200"
15
-
placeholder="Add to the discussion..."></textarea>
16
-
<button type="submit" class="btn flex items-center gap-2">
17
-
{{ i "message-square" "w-4 h-4" }} comment
18
-
</button>
19
-
<button
20
-
type="button"
21
-
class="btn flex items-center gap-2"
22
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
23
-
hx-swap="outerHTML"
24
-
hx-target="#pull-comment-card-{{ .RoundNumber }}">
25
-
{{ i "x" "w-4 h-4" }}
26
-
<span>cancel</span>
27
-
</button>
28
-
<div id="pull-comment"></div>
29
-
</form>
30
-
</div>
31
-
{{ end }}
32
-
-52
appview/pages/templates/fragments/pullResubmit.html
-52
appview/pages/templates/fragments/pullResubmit.html
···
1
-
{{ define "fragments/pullResubmit" }}
2
-
<div
3
-
id="resubmit-pull-card"
4
-
class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2">
5
-
6
-
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-50">
7
-
{{ i "pencil" "w-4 h-4" }}
8
-
<span class="font-medium">resubmit your patch</span>
9
-
</div>
10
-
11
-
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
12
-
You can update this patch to address any reviews.
13
-
This will begin a new round of reviews,
14
-
but you'll still be able to view your previous submissions and feedback.
15
-
</div>
16
-
17
-
<div class="mt-4 flex flex-col">
18
-
<form
19
-
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
20
-
hx-swap="none"
21
-
class="w-full flex flex-wrap gap-2">
22
-
<textarea
23
-
name="patch"
24
-
class="w-full p-2 mb-2"
25
-
placeholder="Paste your updated patch here."
26
-
rows="15"
27
-
>{{.Pull.LatestPatch}}</textarea>
28
-
<button
29
-
type="submit"
30
-
class="btn flex items-center gap-2"
31
-
{{ if or .Pull.State.IsClosed }}
32
-
disabled
33
-
{{ end }}>
34
-
{{ i "rotate-ccw" "w-4 h-4" }}
35
-
<span>resubmit</span>
36
-
</button>
37
-
<button
38
-
type="button"
39
-
class="btn flex items-center gap-2"
40
-
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
41
-
hx-swap="outerHTML"
42
-
hx-target="#resubmit-pull-card">
43
-
{{ i "x" "w-4 h-4" }}
44
-
<span>cancel</span>
45
-
</button>
46
-
</form>
47
-
48
-
<div id="resubmit-error" class="error"></div>
49
-
<div id="resubmit-success" class="success"></div>
50
-
</div>
51
-
</div>
52
-
{{ end }}
-15
appview/pages/templates/fragments/repoDescription.html
-15
appview/pages/templates/fragments/repoDescription.html
···
1
-
{{ define "fragments/repoDescription" }}
2
-
<span id="repo-description" class="flex flex-wrap items-center gap-2" hx-target="this" hx-swap="outerHTML">
3
-
{{ if .RepoInfo.Description }}
4
-
{{ .RepoInfo.Description }}
5
-
{{ else }}
6
-
<span class="italic">this repo has no description</span>
7
-
{{ end }}
8
-
9
-
{{ if .RepoInfo.Roles.IsOwner }}
10
-
<button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
-
{{ i "pencil" "w-3 h-3" }} edit
12
-
</button>
13
-
{{ end }}
14
-
</span>
15
-
{{ end }}
-28
appview/pages/templates/fragments/star.html
-28
appview/pages/templates/fragments/star.html
···
1
-
{{ define "fragments/star" }}
2
-
<button id="starBtn"
3
-
class="text-sm disabled:opacity-50 disabled:cursor-not-allowed"
4
-
5
-
{{ if .IsStarred }}
6
-
hx-delete="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
7
-
{{ else }}
8
-
hx-post="/star?subject={{.RepoAt}}&countHint={{.Stats.StarCount}}"
9
-
{{ end }}
10
-
11
-
hx-trigger="click"
12
-
hx-target="#starBtn"
13
-
hx-swap="outerHTML"
14
-
hx-disabled-elt="#starBtn"
15
-
>
16
-
<div class="flex gap-2 items-center">
17
-
{{ if .IsStarred }}
18
-
{{ i "star" "w-3 h-3 fill-current" }}
19
-
{{ else }}
20
-
{{ i "star" "w-3 h-3" }}
21
-
{{ end }}
22
-
<span>
23
-
{{ .Stats.StarCount }}
24
-
</span>
25
-
</div>
26
-
</button>
27
-
{{ end }}
28
-
+3
-4
appview/pages/templates/layouts/base.html
+3
-4
appview/pages/templates/layouts/base.html
···
8
8
content="width=device-width, initial-scale=1.0"
9
9
/>
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link href="/static/tw.css" rel="stylesheet" type="text/css" />
12
-
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13
12
<title>{{ block "title" . }}{{ end }} ยท tangled</title>
14
13
{{ block "extrameta" . }}{{ end }}
15
14
</head>
16
15
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
17
-
<div class="container mx-auto px-1 pt-4 min-h-screen flex flex-col">
18
-
<header style="z-index: 5">
16
+
<div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col">
17
+
<header style="z-index: 20">
19
18
{{ block "topbar" . }}
20
19
{{ template "layouts/topbar" . }}
21
20
{{ end }}
+30
-15
appview/pages/templates/layouts/repobase.html
+30
-15
appview/pages/templates/layouts/repobase.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
-
<p class="text-lg">
6
-
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
7
-
<span class="select-none">/</span>
8
-
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
9
-
<span class="ml-3">
10
-
{{ template "fragments/star" .RepoInfo }}
11
-
</span>
12
-
</p>
13
-
{{ template "fragments/repoDescription" . }}
14
-
</section>
4
+
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
5
+
{{ if .RepoInfo.Source }}
6
+
<p class="text-sm">
7
+
<div class="flex items-center">
8
+
{{ i "git-fork" "w-3 h-3 mr-1"}}
9
+
forked from
10
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
11
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
12
+
</div>
13
+
</p>
14
+
{{ end }}
15
+
<div class="text-lg flex items-center justify-between">
16
+
<div>
17
+
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
18
+
<span class="select-none">/</span>
19
+
<a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a>
20
+
</div>
21
+
22
+
{{ template "repo/fragments/repoActions" .RepoInfo }}
23
+
</div>
24
+
{{ template "repo/fragments/repoDescription" . }}
25
+
</section>
15
26
<section class="min-h-screen flex flex-col drop-shadow-sm">
16
27
<nav class="w-full pl-4 overflow-auto">
17
28
<div class="flex z-60">
···
21
32
{{ range $item := $tabs }}
22
33
{{ $key := index $item 0 }}
23
34
{{ $value := index $item 1 }}
35
+
{{ $icon := index $item 2 }}
24
36
{{ $meta := index $tabmeta $key }}
25
37
<a
26
38
href="/{{ $.RepoInfo.FullName }}{{ $value }}"
···
36
48
{{ end }}
37
49
"
38
50
>
39
-
{{ $key }}
40
-
{{ if not (isNil $meta) }}
41
-
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm">{{ $meta }}</span>
42
-
{{ end }}
51
+
<span class="flex items-center justify-center">
52
+
{{ i $icon "w-4 h-4 mr-2" }}
53
+
{{ $key }}
54
+
{{ if not (isNil $meta) }}
55
+
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span>
56
+
{{ end }}
57
+
</span>
43
58
</div>
44
59
</a>
45
60
{{ end }}
+13
-2
appview/pages/templates/repo/blob.html
+13
-2
appview/pages/templates/repo/blob.html
···
29
29
>
30
30
/
31
31
{{ else }}
32
-
<span class="text-bold text-gray-600 dark:text-gray-300"
32
+
<span class="text-bold text-black dark:text-white"
33
33
>{{ index . 0 }}</span
34
34
>
35
35
{{ end }}
···
43
43
<span>{{ byteFmt .SizeHint }}</span>
44
44
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
45
45
<a href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/raw/{{ .Path }}">view raw</a>
46
+
{{ if .RenderToggle }}
47
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
48
+
<a
49
+
href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
50
+
hx-boost="true"
51
+
>view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
52
+
{{ end }}
46
53
</div>
47
54
</div>
48
55
</div>
···
52
59
</p>
53
60
{{ else }}
54
61
<div class="overflow-auto relative">
55
-
<div class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
62
+
{{ if .ShowRendered }}
63
+
<div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div>
64
+
{{ else }}
65
+
<div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div>
66
+
{{ end }}
56
67
</div>
57
68
{{ end }}
58
69
{{ end }}
+2
-19
appview/pages/templates/repo/commit.html
+2
-19
appview/pages/templates/repo/commit.html
···
4
4
5
5
{{ $repo := .RepoInfo.FullName }}
6
6
{{ $commit := .Diff.Commit }}
7
-
{{ $stat := .Diff.Stat }}
8
-
{{ $diff := .Diff.Diff }}
9
7
10
8
<section class="commit dark:text-white">
11
9
<div id="commit-message">
···
13
11
<div>
14
12
<p class="pb-2">{{ index $messageParts 0 }}</p>
15
13
{{ if gt (len $messageParts) 1 }}
16
-
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p>
14
+
<p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p>
17
15
{{ end }}
18
16
</div>
19
17
</div>
···
29
27
{{ end }}
30
28
<span class="px-1 select-none before:content-['\00B7']"></span>
31
29
{{ timeFmt $commit.Author.When }}
32
-
<span class="px-1 select-none before:content-['\00B7']"></span>
33
-
<span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span>
34
30
<span class="px-1 select-none before:content-['\00B7']"></span>
35
31
</p>
36
32
···
43
39
</p>
44
40
</div>
45
41
46
-
<div class="diff-stat">
47
-
<br>
48
-
<strong class="text-sm uppercase mb-4 dark:text-gray-200">Changed files</strong>
49
-
{{ range $diff }}
50
-
<ul class="dark:text-gray-200">
51
-
{{ if .IsDelete }}
52
-
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
53
-
{{ else }}
54
-
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
55
-
{{ end }}
56
-
</ul>
57
-
{{ end }}
58
-
</div>
59
42
</section>
60
43
61
44
{{end}}
62
45
63
46
{{ define "repoAfter" }}
64
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
47
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
65
48
{{end}}
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
+38
appview/pages/templates/repo/fork.html
+38
appview/pages/templates/repo/fork.html
···
1
+
{{ define "title" }}fork · {{ .RepoInfo.FullName }}{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
6
+
</div>
7
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
9
+
<fieldset class="space-y-3">
10
+
<legend class="dark:text-white">Select a knot to fork into</legend>
11
+
<div class="space-y-2">
12
+
<div class="flex flex-col">
13
+
{{ range .Knots }}
14
+
<div class="flex items-center">
15
+
<input
16
+
type="radio"
17
+
name="knot"
18
+
value="{{ . }}"
19
+
class="mr-2"
20
+
id="domain-{{ . }}"
21
+
/>
22
+
<span class="dark:text-white">{{ . }}</span>
23
+
</div>
24
+
{{ else }}
25
+
<p class="dark:text-white">No knots available.</p>
26
+
{{ end }}
27
+
</div>
28
+
</div>
29
+
<p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
30
+
</fieldset>
31
+
32
+
<div class="space-y-2">
33
+
<button type="submit" class="btn">fork repo</button>
34
+
<div id="repo" class="error"></div>
35
+
</div>
36
+
</form>
37
+
</div>
38
+
{{ end }}
+51
appview/pages/templates/repo/fragments/cloneInstructions.html
+51
appview/pages/templates/repo/fragments/cloneInstructions.html
···
1
+
{{ define "repo/fragments/cloneInstructions" }}
2
+
<section
3
+
class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4"
4
+
>
5
+
<div class="flex flex-col gap-2">
6
+
<strong>push</strong>
7
+
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
8
+
<code class="dark:text-gray-100"
9
+
>git remote add origin
10
+
git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
11
+
>
12
+
</div>
13
+
</div>
14
+
15
+
<div class="flex flex-col gap-2">
16
+
<strong>clone</strong>
17
+
<div class="md:pl-4 flex flex-col gap-2">
18
+
<div class="flex items-center gap-3">
19
+
<span
20
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
21
+
>HTTP</span
22
+
>
23
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
24
+
<code class="dark:text-gray-100"
25
+
>git clone
26
+
https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code
27
+
>
28
+
</div>
29
+
</div>
30
+
31
+
<div class="flex items-center gap-3">
32
+
<span
33
+
class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white"
34
+
>SSH</span
35
+
>
36
+
<div class="overflow-x-auto whitespace-nowrap flex-1">
37
+
<code class="dark:text-gray-100"
38
+
>git clone
39
+
git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code
40
+
>
41
+
</div>
42
+
</div>
43
+
</div>
44
+
</div>
45
+
46
+
<p class="py-2 text-gray-500 dark:text-gray-400">
47
+
Note that for self-hosted knots, clone URLs may be different based
48
+
on your setup.
49
+
</p>
50
+
</section>
51
+
{{ end }}
+163
appview/pages/templates/repo/fragments/diff.html
+163
appview/pages/templates/repo/fragments/diff.html
···
1
+
{{ define "repo/fragments/diff" }}
2
+
{{ $repo := index . 0 }}
3
+
{{ $diff := index . 1 }}
4
+
{{ $commit := $diff.Commit }}
5
+
{{ $stat := $diff.Stat }}
6
+
{{ $fileTree := fileTree $diff.ChangedFiles }}
7
+
{{ $diff := $diff.Diff }}
8
+
9
+
{{ $this := $commit.This }}
10
+
{{ $parent := $commit.Parent }}
11
+
12
+
<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">
13
+
<div class="diff-stat">
14
+
<div class="flex gap-2 items-center">
15
+
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
16
+
{{ block "statPill" $stat }} {{ end }}
17
+
</div>
18
+
{{ block "fileTree" $fileTree }} {{ end }}
19
+
</div>
20
+
</section>
21
+
22
+
{{ $last := sub (len $diff) 1 }}
23
+
{{ range $idx, $hunk := $diff }}
24
+
{{ with $hunk }}
25
+
<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">
26
+
<div id="file-{{ .Name.New }}">
27
+
<div id="diff-file">
28
+
<details open>
29
+
<summary class="list-none cursor-pointer sticky top-0">
30
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
31
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
32
+
<div class="flex gap-1 items-center">
33
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
34
+
{{ if .IsNew }}
35
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
36
+
{{ else if .IsDelete }}
37
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
38
+
{{ else if .IsCopy }}
39
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
40
+
{{ else if .IsRename }}
41
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
42
+
{{ else }}
43
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
44
+
{{ end }}
45
+
46
+
{{ block "statPill" .Stats }} {{ end }}
47
+
</div>
48
+
49
+
<div class="flex gap-2 items-center overflow-x-auto">
50
+
{{ if .IsDelete }}
51
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
52
+
{{ .Name.Old }}
53
+
</a>
54
+
{{ else if (or .IsCopy .IsRename) }}
55
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
56
+
{{ .Name.Old }}
57
+
</a>
58
+
{{ i "arrow-right" "w-4 h-4" }}
59
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
60
+
{{ .Name.New }}
61
+
</a>
62
+
{{ else }}
63
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
64
+
{{ .Name.New }}
65
+
</a>
66
+
{{ end }}
67
+
</div>
68
+
</div>
69
+
70
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
71
+
<div id="right-side-items" class="p-2 flex items-center">
72
+
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
73
+
{{ if gt $idx 0 }}
74
+
{{ $prev := index $diff (sub $idx 1) }}
75
+
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
76
+
{{ end }}
77
+
78
+
{{ if lt $idx $last }}
79
+
{{ $next := index $diff (add $idx 1) }}
80
+
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
81
+
{{ end }}
82
+
</div>
83
+
84
+
</div>
85
+
</summary>
86
+
87
+
<div class="transition-all duration-700 ease-in-out">
88
+
{{ if .IsDelete }}
89
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
90
+
This file has been deleted.
91
+
</p>
92
+
{{ else if .IsCopy }}
93
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
94
+
This file has been copied.
95
+
</p>
96
+
{{ else if .IsBinary }}
97
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
98
+
This is a binary file and will not be displayed.
99
+
</p>
100
+
{{ else }}
101
+
{{ $name := .Name.New }}
102
+
<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>
103
+
{{- $oldStart := .OldPosition -}}
104
+
{{- $newStart := .NewPosition -}}
105
+
{{- $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 " -}}
106
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
107
+
{{- $lineNrSepStyle1 := "" -}}
108
+
{{- $lineNrSepStyle2 := "pr-2" -}}
109
+
{{- range .Lines -}}
110
+
{{- if eq .Op.String "+" -}}
111
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
112
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
113
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
114
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
115
+
<div class="px-2">{{ .Line }}</div>
116
+
</div>
117
+
{{- $newStart = add64 $newStart 1 -}}
118
+
{{- end -}}
119
+
{{- if eq .Op.String "-" -}}
120
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
121
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
122
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
123
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
124
+
<div class="px-2">{{ .Line }}</div>
125
+
</div>
126
+
{{- $oldStart = add64 $oldStart 1 -}}
127
+
{{- end -}}
128
+
{{- if eq .Op.String " " -}}
129
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
130
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
131
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
132
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
133
+
<div class="px-2">{{ .Line }}</div>
134
+
</div>
135
+
{{- $newStart = add64 $newStart 1 -}}
136
+
{{- $oldStart = add64 $oldStart 1 -}}
137
+
{{- end -}}
138
+
{{- end -}}
139
+
{{- end -}}</div></div></pre>
140
+
{{- end -}}
141
+
</div>
142
+
143
+
</details>
144
+
145
+
</div>
146
+
</div>
147
+
</section>
148
+
{{ end }}
149
+
{{ end }}
150
+
{{ end }}
151
+
152
+
{{ define "statPill" }}
153
+
<div class="flex items-center font-mono text-sm">
154
+
{{ if and .Insertions .Deletions }}
155
+
<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>
156
+
<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>
157
+
{{ else if .Insertions }}
158
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
159
+
{{ else if .Deletions }}
160
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
161
+
{{ end }}
162
+
</div>
163
+
{{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
1
+
{{ define "repo/fragments/editRepoDescription" }}
2
+
<form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2">
3
+
<input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}">
4
+
<button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm">
5
+
{{ i "check" "w-3 h-3" }} save
6
+
</button>
7
+
<button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" >
8
+
{{ i "x" "w-3 h-3" }} cancel
9
+
</button>
10
+
</form>
11
+
{{ end }}
+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 }}
+47
appview/pages/templates/repo/fragments/repoActions.html
+47
appview/pages/templates/repo/fragments/repoActions.html
···
1
+
{{ define "repo/fragments/repoActions" }}
2
+
<div class="flex items-center gap-2 z-auto">
3
+
<button
4
+
id="starBtn"
5
+
class="btn disabled:opacity-50 disabled:cursor-not-allowed"
6
+
{{ if .IsStarred }}
7
+
hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
8
+
{{ else }}
9
+
hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}"
10
+
{{ end }}
11
+
12
+
hx-trigger="click"
13
+
hx-target="#starBtn"
14
+
hx-swap="outerHTML"
15
+
hx-disabled-elt="#starBtn"
16
+
>
17
+
<div class="flex gap-2 items-center">
18
+
{{ if .IsStarred }}
19
+
{{ i "star" "w-4 h-4 fill-current" }}
20
+
{{ else }}
21
+
{{ i "star" "w-4 h-4" }}
22
+
{{ end }}
23
+
<span class="text-sm">
24
+
{{ .Stats.StarCount }}
25
+
</span>
26
+
</div>
27
+
</button>
28
+
{{ if .DisableFork }}
29
+
<button
30
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
31
+
disabled
32
+
title="Empty repositories cannot be forked"
33
+
>
34
+
{{ i "git-fork" "w-4 h-4" }}
35
+
fork
36
+
</button>
37
+
{{ else }}
38
+
<a
39
+
class="btn text-sm no-underline hover:no-underline flex items-center gap-2"
40
+
href="/{{ .FullName }}/fork"
41
+
>
42
+
{{ i "git-fork" "w-4 h-4" }}
43
+
fork
44
+
</a>
45
+
{{ end }}
46
+
</div>
47
+
{{ end }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
+15
appview/pages/templates/repo/fragments/repoDescription.html
···
1
+
{{ define "repo/fragments/repoDescription" }}
2
+
<span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML">
3
+
{{ if .RepoInfo.Description }}
4
+
{{ .RepoInfo.Description }}
5
+
{{ else }}
6
+
<span class="italic">this repo has no description</span>
7
+
{{ end }}
8
+
9
+
{{ if .RepoInfo.Roles.IsOwner }}
10
+
<button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit">
11
+
{{ i "pencil" "w-3 h-3" }}
12
+
</button>
13
+
{{ end }}
14
+
</span>
15
+
{{ end }}
+207
-172
appview/pages/templates/repo/index.html
+207
-172
appview/pages/templates/repo/index.html
···
1
1
{{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }}
2
2
3
-
4
3
{{ define "extrameta" }}
5
-
<meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/>
6
-
<meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}">
7
-
<meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}">
8
-
<meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}">
9
-
<meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}">
10
-
<meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}">
4
+
<meta
5
+
name="vcs:clone"
6
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
7
+
/>
8
+
<meta
9
+
name="forge:summary"
10
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}"
11
+
/>
12
+
<meta
13
+
name="forge:dir"
14
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"
15
+
/>
16
+
<meta
17
+
name="forge:file"
18
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"
19
+
/>
20
+
<meta
21
+
name="forge:line"
22
+
content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"
23
+
/>
24
+
<meta
25
+
name="go-import"
26
+
content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"
27
+
/>
11
28
{{ end }}
12
-
13
29
14
30
{{ define "repoContent" }}
15
31
<main>
16
-
{{ block "branchSelector" . }} {{ end }}
32
+
{{ block "branchSelector" . }}{{ end }}
17
33
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
18
-
{{ block "fileTree" . }} {{ end }}
19
-
{{ block "commitLog" . }} {{ end }}
34
+
{{ block "fileTree" . }}{{ end }}
35
+
{{ block "commitLog" . }}{{ end }}
20
36
</div>
21
37
</main>
22
38
{{ end }}
23
39
24
40
{{ define "branchSelector" }}
25
-
<div class="flex justify-between pb-5">
26
-
<select
27
-
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
28
-
class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
29
-
>
30
-
<optgroup label="branches" class="bold text-sm">
31
-
{{ range .Branches }}
32
-
<option
33
-
value="{{ .Reference.Name }}"
34
-
class="py-1"
35
-
{{ if eq .Reference.Name $.Ref }}
36
-
selected
37
-
{{ end }}
38
-
>
39
-
{{ .Reference.Name }}
40
-
</option>
41
-
{{ end }}
42
-
</optgroup>
43
-
<optgroup label="tags" class="bold text-sm">
44
-
{{ range .Tags }}
45
-
<option
46
-
value="{{ .Reference.Name }}"
47
-
class="py-1"
48
-
{{ if eq .Reference.Name $.Ref }}
49
-
selected
50
-
{{ end }}
51
-
>
52
-
{{ .Reference.Name }}
53
-
</option>
54
-
{{ else }}
55
-
<option class="py-1" disabled>no tags found</option>
56
-
{{ end }}
57
-
</optgroup>
58
-
</select>
59
-
<a
60
-
href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}"
61
-
class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold dark:text-white"
62
-
>
63
-
{{ i "logs" "w-4 h-4" }}
64
-
{{ .TotalCommits }}
65
-
{{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }}
66
-
</a>
67
-
</div>
41
+
<div class="flex justify-between pb-5">
42
+
<select
43
+
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)"
44
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
45
+
>
46
+
<optgroup label="branches" class="bold text-sm">
47
+
{{ range .Branches }}
48
+
<option
49
+
value="{{ .Reference.Name }}"
50
+
class="py-1"
51
+
{{ if eq .Reference.Name $.Ref }}
52
+
selected
53
+
{{ end }}
54
+
>
55
+
{{ .Reference.Name }}
56
+
</option>
57
+
{{ end }}
58
+
</optgroup>
59
+
<optgroup label="tags" class="bold text-sm">
60
+
{{ range .Tags }}
61
+
<option
62
+
value="{{ .Reference.Name }}"
63
+
class="py-1"
64
+
{{ if eq .Reference.Name $.Ref }}
65
+
selected
66
+
{{ end }}
67
+
>
68
+
{{ .Reference.Name }}
69
+
</option>
70
+
{{ else }}
71
+
<option class="py-1" disabled>no tags found</option>
72
+
{{ end }}
73
+
</optgroup>
74
+
</select>
75
+
<a
76
+
href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}"
77
+
class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold dark:text-white"
78
+
>
79
+
{{ i "logs" "w-4 h-4" }}
80
+
{{ .TotalCommits }}
81
+
{{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }}
82
+
</a>
83
+
</div>
68
84
{{ end }}
69
85
70
86
{{ define "fileTree" }}
71
-
<div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700">
72
-
{{ $containerstyle := "py-1" }}
73
-
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
87
+
<div
88
+
id="file-tree"
89
+
class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700"
90
+
>
91
+
{{ $containerstyle := "py-1" }}
92
+
{{ $linkstyle := "no-underline hover:underline dark:text-white" }}
74
93
75
-
{{ range .Files }}
76
-
{{ if not .IsFile }}
77
-
<div class="{{ $containerstyle }}">
78
-
<div class="flex justify-between items-center">
79
-
<a
80
-
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
81
-
class="{{ $linkstyle }}"
82
-
>
83
-
<div class="flex items-center gap-2">
84
-
{{ i "folder" "w-3 h-3 fill-current" }}
85
-
{{ .Name }}
86
-
</div>
87
-
</a>
94
+
{{ range .Files }}
95
+
{{ if not .IsFile }}
96
+
<div class="{{ $containerstyle }}">
97
+
<div class="flex justify-between items-center">
98
+
<a
99
+
href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}"
100
+
class="{{ $linkstyle }}"
101
+
>
102
+
<div class="flex items-center gap-2">
103
+
{{ i "folder" "w-3 h-3 fill-current" }}
104
+
{{ .Name }}
105
+
</div>
106
+
</a>
88
107
89
-
<time class="text-xs text-gray-500 dark:text-gray-400"
90
-
>{{ timeFmt .LastCommit.When }}</time
91
-
>
108
+
<time class="text-xs text-gray-500 dark:text-gray-400"
109
+
>{{ timeFmt .LastCommit.When }}</time
110
+
>
111
+
</div>
92
112
</div>
93
-
</div>
113
+
{{ end }}
94
114
{{ end }}
95
-
{{ end }}
96
115
97
-
{{ range .Files }}
98
-
{{ if .IsFile }}
99
-
<div class="{{ $containerstyle }}">
100
-
<div class="flex justify-between items-center">
101
-
<a
102
-
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
103
-
class="{{ $linkstyle }}"
104
-
>
105
-
<div class="flex items-center gap-2">
106
-
{{ i "file" "w-3 h-3" }}{{ .Name }}
107
-
</div>
108
-
</a>
116
+
{{ range .Files }}
117
+
{{ if .IsFile }}
118
+
<div class="{{ $containerstyle }}">
119
+
<div class="flex justify-between items-center">
120
+
<a
121
+
href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}"
122
+
class="{{ $linkstyle }}"
123
+
>
124
+
<div class="flex items-center gap-2">
125
+
{{ i "file" "w-3 h-3" }}{{ .Name }}
126
+
</div>
127
+
</a>
109
128
110
-
<time class="text-xs text-gray-500 dark:text-gray-400"
111
-
>{{ timeFmt .LastCommit.When }}</time
112
-
>
129
+
<time class="text-xs text-gray-500 dark:text-gray-400"
130
+
>{{ timeFmt .LastCommit.When }}</time
131
+
>
132
+
</div>
113
133
</div>
114
-
</div>
134
+
{{ end }}
115
135
{{ end }}
116
-
{{ end }}
117
-
</div>
136
+
</div>
118
137
{{ end }}
119
138
120
-
121
139
{{ define "commitLog" }}
122
-
<div id="commit-log" class="hidden md:block md:col-span-1">
123
-
{{ range .Commits }}
124
-
<div class="relative px-2 pb-8">
125
-
<div id="commit-message">
126
-
{{ $messageParts := splitN .Message "\n\n" 2 }}
127
-
<div class="text-base cursor-pointer">
128
-
<div>
129
-
<div>
130
-
<a
131
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
132
-
class="inline no-underline hover:underline dark:text-white"
133
-
>{{ index $messageParts 0 }}</a
134
-
>
135
-
{{ if gt (len $messageParts) 1 }}
140
+
<div id="commit-log" class="hidden md:block md:col-span-1">
141
+
{{ range .Commits }}
142
+
<div class="relative px-2 pb-8">
143
+
<div id="commit-message">
144
+
{{ $messageParts := splitN .Message "\n\n" 2 }}
145
+
<div class="text-base cursor-pointer">
146
+
<div>
147
+
<div>
148
+
<a
149
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
150
+
class="inline no-underline hover:underline dark:text-white"
151
+
>{{ index $messageParts 0 }}</a
152
+
>
153
+
{{ if gt (len $messageParts) 1 }}
136
154
137
-
<button
138
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
139
-
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
140
-
>
141
-
{{ i "ellipsis" "w-3 h-3" }}
142
-
</button>
143
-
{{ end }}
144
-
</div>
145
-
{{ if gt (len $messageParts) 1 }}
146
-
<p
147
-
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
148
-
>
149
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
150
-
</p>
151
-
{{ end }}
152
-
</div>
153
-
</div>
154
-
</div>
155
+
<button
156
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600"
157
+
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
158
+
>
159
+
{{ i "ellipsis" "w-3 h-3" }}
160
+
</button>
161
+
{{ end }}
162
+
</div>
163
+
{{ if gt (len $messageParts) 1 }}
164
+
<p
165
+
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
166
+
>
167
+
{{ nl2br (index $messageParts 1) }}
168
+
</p>
169
+
{{ end }}
170
+
</div>
171
+
</div>
172
+
</div>
155
173
156
-
<div class="text-xs text-gray-500 dark:text-gray-400">
157
-
<span class="font-mono">
158
-
<a
159
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
160
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
161
-
>{{ slice .Hash.String 0 8 }}</a
162
-
>
163
-
</span>
164
-
<span
165
-
class="mx-2 before:content-['ยท'] before:select-none"
166
-
></span>
167
-
<span>
168
-
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
169
-
<a
170
-
href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}"
171
-
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
172
-
>{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ .Author.Name }}{{ end }}</a
173
-
>
174
-
</span>
175
-
<div
176
-
class="inline-block px-1 select-none after:content-['ยท']"
177
-
></div>
178
-
<span>{{ timeFmt .Author.When }}</span>
179
-
{{ $tagsForCommit := index $.TagMap .Hash.String }}
180
-
{{ if gt (len $tagsForCommit) 0 }}
174
+
<div class="text-xs text-gray-500 dark:text-gray-400">
175
+
<span class="font-mono">
176
+
<a
177
+
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
178
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
179
+
>{{ slice .Hash.String 0 8 }}</a></span>
180
+
<span
181
+
class="mx-2 before:content-['ยท'] before:select-none"
182
+
></span>
183
+
<span>
184
+
{{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }}
185
+
<a
186
+
href="{{ if $didOrHandle }}
187
+
/{{ $didOrHandle }}
188
+
{{ else }}
189
+
mailto:{{ .Author.Email }}
190
+
{{ end }}"
191
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
192
+
>{{ if $didOrHandle }}
193
+
{{ $didOrHandle }}
194
+
{{ else }}
195
+
{{ .Author.Name }}
196
+
{{ end }}</a
197
+
>
198
+
</span>
181
199
<div
182
200
class="inline-block px-1 select-none after:content-['ยท']"
183
201
></div>
184
-
{{ end }}
185
-
{{ range $tagsForCommit }}
186
-
<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">
187
-
{{ . }}
188
-
</span>
189
-
{{ end }}
190
-
</div>
191
-
</div>
192
-
{{ end }}
193
-
</div>
202
+
<span>{{ timeFmt .Author.When }}</span>
203
+
{{ $tagsForCommit := index $.TagMap .Hash.String }}
204
+
{{ if gt (len $tagsForCommit) 0 }}
205
+
<div
206
+
class="inline-block px-1 select-none after:content-['ยท']"
207
+
></div>
208
+
{{ end }}
209
+
{{ range $tagsForCommit }}
210
+
<span
211
+
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"
212
+
>
213
+
{{ . }}
214
+
</span>
215
+
{{ end }}
216
+
</div>
217
+
</div>
218
+
{{ end }}
219
+
</div>
194
220
{{ end }}
195
-
196
221
197
222
{{ define "repoAfter" }}
198
223
{{- if .HTMLReadme }}
199
-
<section class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }} prose dark:prose-invert dark:[&_pre]:bg-gray-900 dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 dark:[&_pre]:border dark:[&_pre]:border-gray-700 {{ end }}">
200
-
<article class="{{ if .Raw }}whitespace-pre{{end}}">
201
-
{{ if .Raw }}
202
-
<pre class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded">{{ .HTMLReadme }}</pre>
203
-
{{ else }}
204
-
{{ .HTMLReadme }}
205
-
{{ end }}
206
-
</article>
207
-
</section>
224
+
<section
225
+
class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }}
226
+
prose dark:prose-invert dark:[&_pre]:bg-gray-900
227
+
dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900
228
+
dark:[&_pre]:border dark:[&_pre]:border-gray-700
229
+
{{ end }}"
230
+
>
231
+
<article class="{{ if .Raw }}whitespace-pre{{ end }}">
232
+
{{ if .Raw }}
233
+
<pre
234
+
class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded"
235
+
>
236
+
{{ .HTMLReadme }}</pre
237
+
>
238
+
{{ else }}
239
+
{{ .HTMLReadme }}
240
+
{{ end }}
241
+
</article>
242
+
</section>
208
243
{{- end -}}
209
244
210
-
{{ template "fragments/cloneInstructions" . }}
245
+
{{ template "repo/fragments/cloneInstructions" . }}
211
246
{{ end }}
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
1
+
{{ define "repo/issues/fragments/editIssueComment" }}
2
+
{{ with .Comment }}
3
+
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 text-sm">
5
+
{{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }}
6
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
+
8
+
<!-- show user "hats" -->
9
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
10
+
{{ if $isIssueAuthor }}
11
+
<span class="before:content-['ยท']"></span>
12
+
<span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
13
+
author
14
+
</span>
15
+
{{ end }}
16
+
17
+
<span class="before:content-['ยท']"></span>
18
+
<a
19
+
href="#{{ .CommentId }}"
20
+
class="text-gray-500 hover:text-gray-500 hover:underline no-underline"
21
+
id="{{ .CommentId }}">
22
+
{{ .Created | timeFmt }}
23
+
</a>
24
+
25
+
<button
26
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
27
+
hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
28
+
hx-include="#edit-textarea-{{ .CommentId }}"
29
+
hx-target="#comment-container-{{ .CommentId }}"
30
+
hx-swap="outerHTML">
31
+
{{ i "check" "w-4 h-4" }}
32
+
</button>
33
+
<button
34
+
class="btn px-2 py-1 flex items-center gap-2 text-sm"
35
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
36
+
hx-target="#comment-container-{{ .CommentId }}"
37
+
hx-swap="outerHTML">
38
+
{{ i "x" "w-4 h-4" }}
39
+
</button>
40
+
<span id="comment-{{.CommentId}}-status"></span>
41
+
</div>
42
+
43
+
<div>
44
+
<textarea
45
+
id="edit-textarea-{{ .CommentId }}"
46
+
name="body"
47
+
class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea>
48
+
</div>
49
+
</div>
50
+
{{ end }}
51
+
{{ end }}
52
+
+59
appview/pages/templates/repo/issues/fragments/issueComment.html
+59
appview/pages/templates/repo/issues/fragments/issueComment.html
···
1
+
{{ define "repo/issues/fragments/issueComment" }}
2
+
{{ with .Comment }}
3
+
<div id="comment-container-{{.CommentId}}">
4
+
<div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm">
5
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
6
+
<a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a>
7
+
8
+
<span class="before:content-['ยท']"></span>
9
+
<a
10
+
href="#{{ .CommentId }}"
11
+
class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline"
12
+
id="{{ .CommentId }}">
13
+
{{ if .Deleted }}
14
+
deleted {{ .Deleted | timeFmt }}
15
+
{{ else if .Edited }}
16
+
edited {{ .Edited | timeFmt }}
17
+
{{ else }}
18
+
{{ .Created | timeFmt }}
19
+
{{ end }}
20
+
</a>
21
+
22
+
<!-- show user "hats" -->
23
+
{{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }}
24
+
{{ if $isIssueAuthor }}
25
+
<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">
26
+
author
27
+
</span>
28
+
{{ end }}
29
+
30
+
{{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }}
31
+
{{ if and $isCommentOwner (not .Deleted) }}
32
+
<button
33
+
class="btn px-2 py-1 text-sm"
34
+
hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit"
35
+
hx-swap="outerHTML"
36
+
hx-target="#comment-container-{{.CommentId}}"
37
+
>
38
+
{{ i "pencil" "w-4 h-4" }}
39
+
</button>
40
+
<button
41
+
class="btn px-2 py-1 text-sm text-red-500"
42
+
hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/"
43
+
hx-confirm="Are you sure you want to delete your comment?"
44
+
hx-swap="outerHTML"
45
+
hx-target="#comment-container-{{.CommentId}}"
46
+
>
47
+
{{ i "trash-2" "w-4 h-4" }}
48
+
</button>
49
+
{{ end }}
50
+
51
+
</div>
52
+
{{ if not .Deleted }}
53
+
<div class="prose dark:prose-invert">
54
+
{{ .Body | markdown }}
55
+
</div>
56
+
{{ end }}
57
+
</div>
58
+
{{ end }}
59
+
{{ end }}
+112
-42
appview/pages/templates/repo/issues/issue.html
+112
-42
appview/pages/templates/repo/issues/issue.html
···
44
44
{{ end }}
45
45
46
46
{{ define "repoAfter" }}
47
-
{{ if gt (len .Comments) 0 }}
48
-
<section id="comments" class="mt-8 space-y-4 relative">
47
+
<section id="comments" class="my-2 mt-2 space-y-2 relative">
49
48
{{ range $index, $comment := .Comments }}
50
49
<div
51
50
id="comment-{{ .CommentId }}"
52
-
class="rounded bg-white px-6 py-4 relative dark:bg-gray-800">
53
-
{{ if eq $index 0 }}
54
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
55
-
{{ else }}
56
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-700" ></div>
51
+
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">
52
+
{{ if gt $index 0 }}
53
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
57
54
{{ end }}
58
-
59
-
{{ template "fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
55
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
60
56
</div>
61
57
{{ end }}
62
58
</section>
63
-
{{ end }}
64
59
65
60
{{ block "newComment" . }} {{ end }}
66
61
67
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
68
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
69
-
{{ if or $isIssueAuthor $isRepoCollaborator }}
70
-
{{ $action := "close" }}
71
-
{{ $icon := "circle-x" }}
72
-
{{ $hoverColor := "red" }}
73
-
{{ if eq .State "closed" }}
74
-
{{ $action = "reopen" }}
75
-
{{ $icon = "circle-dot" }}
76
-
{{ $hoverColor = "green" }}
77
-
{{ end }}
78
-
<form
79
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}"
80
-
hx-swap="none"
81
-
class="mt-8"
82
-
>
83
-
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
84
-
{{ i $icon "w-4 h-4 mr-2" }}
85
-
<span class="text-black dark:text-gray-400">{{ $action }}</span>
86
-
</button>
87
-
<div id="issue-action" class="error"></div>
88
-
</form>
89
-
{{ end }}
90
62
{{ end }}
91
63
92
64
{{ define "newComment" }}
93
65
{{ if .LoggedInUser }}
94
-
<div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8 dark:bg-gray-800 dark:text-gray-400">
95
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
96
-
<div class="text-sm text-gray-500 dark:text-gray-400">
66
+
<form
67
+
id="comment-form"
68
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
69
+
hx-on::after-request="if(event.detail.successful) this.reset()"
70
+
>
71
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5">
72
+
<div class="text-sm pb-2 text-gray-500 dark:text-gray-400">
97
73
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
98
74
</div>
99
-
<form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment">
100
75
<textarea
76
+
id="comment-textarea"
101
77
name="body"
102
78
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
103
-
placeholder="Add to the discussion..."
79
+
placeholder="Add to the discussion. Markdown is supported."
80
+
onkeyup="updateCommentForm()"
104
81
></textarea>
105
-
<button type="submit" class="btn mt-2">comment</button>
106
82
<div id="issue-comment"></div>
107
-
</form>
83
+
<div id="issue-action" class="error"></div>
108
84
</div>
85
+
86
+
<div class="flex gap-2 mt-2">
87
+
<button
88
+
id="comment-button"
89
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
90
+
type="submit"
91
+
hx-disabled-elt="#comment-button"
92
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"
93
+
disabled
94
+
>
95
+
{{ i "message-square-plus" "w-4 h-4" }}
96
+
comment
97
+
</button>
98
+
99
+
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
100
+
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
101
+
{{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }}
102
+
<button
103
+
id="close-button"
104
+
type="button"
105
+
class="btn flex items-center gap-2"
106
+
hx-trigger="click"
107
+
>
108
+
{{ i "ban" "w-4 h-4" }}
109
+
close
110
+
</button>
111
+
<div
112
+
id="close-with-comment"
113
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"
114
+
hx-trigger="click from:#close-button"
115
+
hx-disabled-elt="#close-with-comment"
116
+
hx-target="#issue-comment"
117
+
hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}"
118
+
hx-swap="none"
119
+
>
120
+
</div>
121
+
<div
122
+
id="close-issue"
123
+
hx-disabled-elt="#close-issue"
124
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close"
125
+
hx-trigger="click from:#close-button"
126
+
hx-target="#issue-action"
127
+
hx-swap="none"
128
+
>
129
+
</div>
130
+
<script>
131
+
document.addEventListener('htmx:configRequest', function(evt) {
132
+
if (evt.target.id === 'close-with-comment') {
133
+
const commentText = document.getElementById('comment-textarea').value.trim();
134
+
if (commentText === '') {
135
+
evt.detail.parameters = {};
136
+
evt.preventDefault();
137
+
}
138
+
}
139
+
});
140
+
</script>
141
+
{{ else if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "closed") }}
142
+
<button
143
+
type="button"
144
+
class="btn flex items-center gap-2"
145
+
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen"
146
+
hx-swap="none"
147
+
>
148
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
149
+
reopen
150
+
</button>
151
+
{{ end }}
152
+
153
+
<script>
154
+
function updateCommentForm() {
155
+
const textarea = document.getElementById('comment-textarea');
156
+
const commentButton = document.getElementById('comment-button');
157
+
const closeButton = document.getElementById('close-button');
158
+
159
+
if (textarea.value.trim() !== '') {
160
+
commentButton.removeAttribute('disabled');
161
+
} else {
162
+
commentButton.setAttribute('disabled', '');
163
+
}
164
+
165
+
if (closeButton) {
166
+
if (textarea.value.trim() !== '') {
167
+
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close with comment';
168
+
} else {
169
+
closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close';
170
+
}
171
+
}
172
+
}
173
+
174
+
document.addEventListener('DOMContentLoaded', function() {
175
+
updateCommentForm();
176
+
});
177
+
</script>
178
+
</div>
179
+
</form>
109
180
{{ else }}
110
-
<div class="bg-white dark:bg-gray-800 dark:text-gray-400 rounded drop-shadow-sm px-6 py-4 mt-8">
111
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300 dark:bg-gray-700" ></div>
181
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
112
182
<a href="/login" class="underline">login</a> to join the discussion
113
183
</div>
114
184
{{ end }}
+41
-3
appview/pages/templates/repo/issues/issues.html
+41
-3
appview/pages/templates/repo/issues/issues.html
···
4
4
<div class="flex justify-between items-center">
5
5
<p>
6
6
filtering
7
-
<select class="border px-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
7
+
<select class="border p-1 bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value">
8
8
<option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option>
9
9
<option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option>
10
10
</select>
···
13
13
<a
14
14
href="/{{ .RepoInfo.FullName }}/issues/new"
15
15
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline">
16
-
{{ i "plus" "w-4 h-4" }}
17
-
<span>new issue</span>
16
+
{{ i "circle-plus" "w-4 h-4" }}
17
+
<span>new</span>
18
18
</a>
19
19
</div>
20
20
<div class="error" id="issues"></div>
···
69
69
</p>
70
70
</div>
71
71
{{ end }}
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 }}
72
110
</div>
73
111
{{ end }}
+1
-1
appview/pages/templates/repo/issues/new.html
+1
-1
appview/pages/templates/repo/issues/new.html
+1
-1
appview/pages/templates/repo/log.html
+1
-1
appview/pages/templates/repo/log.html
+90
appview/pages/templates/repo/pulls/fragments/pullActions.html
+90
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
1
+
{{ define "repo/pulls/fragments/pullActions" }}
2
+
{{ $lastIdx := sub (len .Pull.Submissions) 1 }}
3
+
{{ $roundNumber := .RoundNumber }}
4
+
5
+
{{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }}
6
+
{{ $isMerged := .Pull.State.IsMerged }}
7
+
{{ $isClosed := .Pull.State.IsClosed }}
8
+
{{ $isOpen := .Pull.State.IsOpen }}
9
+
{{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }}
10
+
{{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }}
11
+
{{ $isLastRound := eq $roundNumber $lastIdx }}
12
+
{{ $isSameRepoBranch := .Pull.IsBranchBased }}
13
+
{{ $isUpToDate := .ResubmitCheck.No }}
14
+
<div class="relative w-fit">
15
+
<div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2">
16
+
<button
17
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment"
18
+
hx-target="#actions-{{$roundNumber}}"
19
+
hx-swap="outerHtml"
20
+
class="btn p-2 flex items-center gap-2 no-underline hover:no-underline">
21
+
{{ i "message-square-plus" "w-4 h-4" }}
22
+
<span>comment</span>
23
+
</button>
24
+
{{ if and $isPushAllowed $isOpen $isLastRound }}
25
+
{{ $disabled := "" }}
26
+
{{ if $isConflicted }}
27
+
{{ $disabled = "disabled" }}
28
+
{{ end }}
29
+
<button
30
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge"
31
+
hx-swap="none"
32
+
hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?"
33
+
class="btn p-2 flex items-center gap-2" {{ $disabled }}>
34
+
{{ i "git-merge" "w-4 h-4" }}
35
+
<span>merge</span>
36
+
</button>
37
+
{{ end }}
38
+
39
+
{{ if and $isPullAuthor $isOpen $isLastRound }}
40
+
{{ $disabled := "" }}
41
+
{{ if $isUpToDate }}
42
+
{{ $disabled = "disabled" }}
43
+
{{ end }}
44
+
<button id="resubmitBtn"
45
+
{{ if not .Pull.IsPatchBased }}
46
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
47
+
{{ else }}
48
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
49
+
hx-target="#actions-{{$roundNumber}}"
50
+
hx-swap="outerHtml"
51
+
{{ end }}
52
+
53
+
hx-disabled-elt="#resubmitBtn"
54
+
class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }}
55
+
56
+
{{ if $disabled }}
57
+
title="Update this branch to resubmit this pull request"
58
+
{{ else }}
59
+
title="Resubmit this pull request"
60
+
{{ end }}
61
+
>
62
+
{{ i "rotate-ccw" "w-4 h-4" }}
63
+
<span>resubmit</span>
64
+
</button>
65
+
{{ end }}
66
+
67
+
{{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }}
68
+
<button
69
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close"
70
+
hx-swap="none"
71
+
class="btn p-2 flex items-center gap-2">
72
+
{{ i "ban" "w-4 h-4" }}
73
+
<span>close</span>
74
+
</button>
75
+
{{ end }}
76
+
77
+
{{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }}
78
+
<button
79
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen"
80
+
hx-swap="none"
81
+
class="btn p-2 flex items-center gap-2">
82
+
{{ i "refresh-ccw-dot" "w-4 h-4" }}
83
+
<span>reopen</span>
84
+
</button>
85
+
{{ end }}
86
+
</div>
87
+
</div>
88
+
{{ end }}
89
+
90
+
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
1
+
{{ define "repo/pulls/fragments/pullCompareBranches" }}
2
+
<div id="patch-upload">
3
+
<label for="targetBranch" class="dark:text-white"
4
+
>select a branch</label
5
+
>
6
+
<div class="flex flex-wrap gap-2 items-center">
7
+
<select
8
+
name="sourceBranch"
9
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
10
+
>
11
+
<option disabled selected>source branch</option>
12
+
{{ range .Branches }}
13
+
<option value="{{ .Reference.Name }}" class="py-1">
14
+
{{ .Reference.Name }}
15
+
</option>
16
+
{{ end }}
17
+
</select>
18
+
</div>
19
+
</div>
20
+
21
+
<p class="mt-4">
22
+
Title and description are optional; if left out, they will be extracted
23
+
from the first commit.
24
+
</p>
25
+
{{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
1
+
{{ define "repo/pulls/fragments/pullCompareForks" }}
2
+
<div id="patch-upload">
3
+
<label for="forkSelect" class="dark:text-white"
4
+
>select a fork to compare</label
5
+
>
6
+
<div class="flex flex-wrap gap-4 items-center mb-4">
7
+
<div class="flex flex-wrap gap-2 items-center">
8
+
<select
9
+
id="forkSelect"
10
+
name="fork"
11
+
required
12
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
13
+
hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches"
14
+
hx-target="#branch-selection"
15
+
hx-vals='{"fork": this.value}'
16
+
hx-swap="innerHTML"
17
+
onchange="document.getElementById('hiddenForkInput').value = this.value;"
18
+
>
19
+
<option disabled selected>select a fork</option>
20
+
{{ range .Forks }}
21
+
<option value="{{ .Name }}" class="py-1">
22
+
{{ .Name }}
23
+
</option>
24
+
{{ end }}
25
+
</select>
26
+
27
+
<input
28
+
type="hidden"
29
+
id="hiddenForkInput"
30
+
name="fork"
31
+
value=""
32
+
/>
33
+
</div>
34
+
35
+
<div id="branch-selection">
36
+
<div class="text-sm text-gray-500 dark:text-gray-400">
37
+
Select a fork first to view available branches
38
+
</div>
39
+
</div>
40
+
</div>
41
+
</div>
42
+
<p class="mt-4">
43
+
Title and description are optional; if left out, they will be extracted
44
+
from the first commit.
45
+
</p>
46
+
{{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
1
+
{{ define "repo/pulls/fragments/pullCompareForksBranches" }}
2
+
<div class="flex flex-wrap gap-2 items-center">
3
+
<select
4
+
name="sourceBranch"
5
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
6
+
>
7
+
<option disabled selected>source branch</option>
8
+
{{ range .SourceBranches }}
9
+
<option value="{{ .Reference.Name }}" class="py-1">
10
+
{{ .Reference.Name }}
11
+
</option>
12
+
{{ end }}
13
+
</select>
14
+
</div>
15
+
{{ end }}
+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 }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
1
+
{{ define "repo/pulls/fragments/pullNewComment" }}
2
+
<div
3
+
id="pull-comment-card-{{ .RoundNumber }}"
4
+
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
+
<div class="text-sm text-gray-500 dark:text-gray-400">
6
+
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
7
+
</div>
8
+
<form
9
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
10
+
hx-swap="none"
11
+
class="w-full flex flex-wrap gap-2">
12
+
<textarea
13
+
name="body"
14
+
class="w-full p-2 rounded border border-gray-200"
15
+
placeholder="Add to the discussion..."></textarea>
16
+
<button type="submit" class="btn flex items-center gap-2">
17
+
{{ i "message-square" "w-4 h-4" }} comment
18
+
</button>
19
+
<button
20
+
type="button"
21
+
class="btn flex items-center gap-2"
22
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions"
23
+
hx-swap="outerHTML"
24
+
hx-target="#pull-comment-card-{{ .RoundNumber }}">
25
+
{{ i "x" "w-4 h-4" }}
26
+
<span>cancel</span>
27
+
</button>
28
+
<div id="pull-comment"></div>
29
+
</form>
30
+
</div>
31
+
{{ end }}
32
+
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
1
+
{{ define "repo/pulls/fragments/pullPatchUpload" }}
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>
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"
11
+
name="patch"
12
+
id="patch"
13
+
rows="12"
14
+
class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
15
+
placeholder="diff --git a/file.txt b/file.txt
16
+
index 1234567..abcdefg 100644
17
+
--- a/file.txt
18
+
+++ b/file.txt"
19
+
></textarea>
20
+
</div>
21
+
{{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
1
+
{{ define "repo/pulls/fragments/pullResubmit" }}
2
+
<div
3
+
id="resubmit-pull-card"
4
+
class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2">
5
+
6
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-50">
7
+
{{ i "pencil" "w-4 h-4" }}
8
+
<span class="font-medium">resubmit your patch</span>
9
+
</div>
10
+
11
+
<div class="mt-2 text-sm text-gray-700 dark:text-gray-200">
12
+
You can update this patch to address any reviews.
13
+
This will begin a new round of reviews,
14
+
but you'll still be able to view your previous submissions and feedback.
15
+
</div>
16
+
17
+
<div class="mt-4 flex flex-col">
18
+
<form
19
+
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit"
20
+
hx-swap="none"
21
+
class="w-full flex flex-wrap gap-2">
22
+
<textarea
23
+
name="patch"
24
+
class="w-full p-2 mb-2"
25
+
placeholder="Paste your updated patch here."
26
+
rows="15"
27
+
>{{.Pull.LatestPatch}}</textarea>
28
+
<button
29
+
type="submit"
30
+
class="btn flex items-center gap-2"
31
+
{{ if or .Pull.State.IsClosed }}
32
+
disabled
33
+
{{ end }}>
34
+
{{ i "rotate-ccw" "w-4 h-4" }}
35
+
<span>resubmit</span>
36
+
</button>
37
+
<button
38
+
type="button"
39
+
class="btn flex items-center gap-2"
40
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions"
41
+
hx-swap="outerHTML"
42
+
hx-target="#resubmit-pull-card">
43
+
{{ i "x" "w-4 h-4" }}
44
+
<span>cancel</span>
45
+
</button>
46
+
</form>
47
+
48
+
<div id="resubmit-error" class="error"></div>
49
+
<div id="resubmit-success" class="success"></div>
50
+
</div>
51
+
</div>
52
+
{{ 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
+
+82
-36
appview/pages/templates/repo/pulls/new.html
+82
-36
appview/pages/templates/repo/pulls/new.html
···
1
-
{{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }}
1
+
{{ define "title" }}new pull · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
-
<section class="prose dark:prose-invert">
5
-
<p>
6
-
This is v1 of the pull request flow. Paste your patch in the form below.
7
-
Here are the steps to get you started:
8
-
<ul class="list-decimal pl-10 space-y-2 text-gray-700 dark:text-gray-300">
9
-
<li class="leading-relaxed">Clone this repository.</li>
10
-
<li class="leading-relaxed">Make your changes in your local repository.</li>
11
-
<li class="leading-relaxed">Grab the diff using <code class="bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded text-gray-800 dark:text-gray-200 font-mono text-sm">git diff</code>.</li>
12
-
<li class="leading-relaxed">Paste the diff output in the form below.</li>
13
-
</ul>
14
-
</p>
15
-
</section>
16
4
<form
17
5
hx-post="/{{ .RepoInfo.FullName }}/pulls/new"
18
6
class="mt-6 space-y-6"
19
7
hx-swap="none"
20
8
>
21
9
<div class="flex flex-col gap-4">
22
-
<div>
23
-
<label for="title" class="dark:text-white">write a title</label>
24
-
<input type="text" name="title" id="title" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" />
10
+
<label>configure your pull request</label>
25
11
26
-
<label for="targetBranch" class="dark:text-white">select a target branch</label>
27
-
<p class="text-gray-500 dark:text-gray-400">
28
-
The branch you want to make your change against.
29
-
</p>
12
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
13
+
<div class="pb-2">
30
14
<select
15
+
required
31
16
name="targetBranch"
32
-
class="p-1 mb-2 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
33
18
>
34
-
<option disabled selected>select a branch</option>
19
+
<option disabled selected>target branch</option>
35
20
{{ range .Branches }}
36
21
<option value="{{ .Reference.Name }}" class="py-1">
37
22
{{ .Reference.Name }}
38
23
</option>
39
24
{{ end }}
40
25
</select>
41
-
<label for="body" class="dark:text-white">add a description</label>
26
+
</div>
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>
39
+
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 }}
54
+
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>
75
+
76
+
<div id="patch-error" class="error dark:text-red-300"></div>
77
+
78
+
<div>
79
+
<label for="title" class="dark:text-white">write a title</label>
80
+
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>
89
+
90
+
<div>
91
+
<label for="body" class="dark:text-white"
92
+
>add a description</label
93
+
>
94
+
42
95
<textarea
43
96
name="body"
44
97
id="body"
···
46
99
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
47
100
placeholder="Describe your change. Markdown is supported."
48
101
></textarea>
102
+
</div>
49
103
50
-
<div class="mt-4">
51
-
<label for="patch" class="dark:text-white">paste your patch here</label>
52
-
<textarea
53
-
name="patch"
54
-
id="patch"
55
-
rows="10"
56
-
class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
57
-
placeholder="Paste your git diff output here."
58
-
></textarea>
59
-
</div>
60
-
</div>
61
-
<div>
62
-
<button type="submit" class="btn dark:bg-gray-600 dark:hover:bg-gray-500 dark:text-white">create</button>
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>
63
109
</div>
64
110
</div>
65
111
<div id="pull" class="error dark:text-red-300"></div>
+21
-84
appview/pages/templates/repo/pulls/patch.html
+21
-84
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 id="diff-stat">
73
-
<br>
74
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
75
-
{{ range .Diff.Diff }}
76
-
<ul>
77
-
{{ if .IsDelete }}
78
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
79
-
{{ else }}
80
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
81
-
{{ end }}
82
-
</ul>
83
-
{{ end }}
84
-
</div>
85
-
</div>
86
-
87
-
<section>
88
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
89
-
</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>
90
27
{{ end }}
+96
-68
appview/pages/templates/repo/pulls/pull.html
+96
-68
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>
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>targeting branch
43
-
<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">
44
-
{{ .Pull.TargetBranch }}
45
-
</span>
46
-
</span>
47
-
</span>
48
-
</div>
49
-
50
-
{{ if .Pull.Body }}
51
-
<article id="body" class="mt-2 prose dark:prose-invert">
52
-
{{ .Pull.Body | markdown }}
53
-
</article>
54
-
{{ end }}
55
-
</section>
56
-
6
+
{{ template "repo/pulls/fragments/pullHeader" . }}
57
7
{{ end }}
58
8
59
9
{{ define "repoAfter" }}
···
72
22
{{ $targetBranch := .Pull.TargetBranch }}
73
23
{{ $repoName := .RepoInfo.FullName }}
74
24
{{ range $idx, $item := .Pull.Submissions }}
75
-
{{ $diff := $item.AsNiceDiff $targetBranch }}
76
25
{{ with $item }}
77
26
<details {{ if eq $idx $lastIdx }}open{{ end }}>
78
27
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
79
28
<div class="flex flex-wrap gap-2 items-center">
80
29
<!-- round number -->
81
30
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
82
-
#{{ .RoundNumber }}
31
+
<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span>
83
32
</div>
84
33
<!-- round summary -->
85
34
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
···
101
50
{{ len .Comments }} comment{{$s}}
102
51
</span>
103
52
</div>
104
-
<!-- view patch -->
53
+
105
54
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2"
106
55
hx-boost="true"
107
56
href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}">
108
57
{{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span>
109
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 }}
110
67
</div>
111
68
</summary>
112
-
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
113
-
{{ range .Comments }}
114
-
<div id="comment-{{.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative 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 }}
115
132
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
133
+
{{ end }}
116
134
<div class="text-sm text-gray-500 dark:text-gray-400">
117
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
135
+
{{ $owner := index $.DidHandleMap $c.OwnerDid }}
118
136
<a href="/{{$owner}}">{{$owner}}</a>
119
137
<span class="before:content-['ยท']"></span>
120
-
<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>
121
139
</div>
122
140
<div class="prose dark:prose-invert">
123
-
{{ .Body | markdown }}
141
+
{{ $c.Body | markdown }}
124
142
</div>
125
143
</div>
126
144
{{ end }}
127
145
128
146
{{ if eq $lastIdx .RoundNumber }}
129
147
{{ block "mergeStatus" $ }} {{ end }}
148
+
{{ block "resubmitStatus" $ }} {{ end }}
130
149
{{ end }}
131
150
132
151
{{ if $.LoggedInUser }}
133
-
{{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }}
152
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }}
134
153
{{ else }}
135
154
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
136
155
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
···
139
158
{{ end }}
140
159
</div>
141
160
</details>
142
-
<hr class="md:hidden"/>
161
+
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
143
162
{{ end }}
144
163
{{ end }}
145
164
{{ end }}
···
147
166
{{ define "mergeStatus" }}
148
167
{{ if .Pull.State.IsClosed }}
149
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">
150
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
151
169
<div class="flex items-center gap-2 text-black dark:text-white">
152
170
{{ i "ban" "w-4 h-4" }}
153
171
<span class="font-medium">closed without merging</span
···
156
174
</div>
157
175
{{ else if .Pull.State.IsMerged }}
158
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">
159
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
160
177
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
161
178
{{ i "git-merge" "w-4 h-4" }}
162
179
<span class="font-medium">pull request successfully merged</span
···
165
182
</div>
166
183
{{ else if and .MergeCheck .MergeCheck.Error }}
167
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">
168
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
169
185
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
170
186
{{ i "triangle-alert" "w-4 h-4" }}
171
187
<span class="font-medium">{{ .MergeCheck.Error }}</span>
···
173
189
</div>
174
190
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
175
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">
176
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
177
-
<div class="flex flex-col items-center gap-2 text-red-500 dark:text-red-300">
192
+
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
178
193
<div class="flex items-center gap-2">
179
194
{{ i "triangle-alert" "w-4 h-4" }}
180
195
<span class="font-medium">merge conflicts detected</span>
···
193
208
</div>
194
209
{{ else if .MergeCheck }}
195
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">
196
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
197
211
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
198
212
{{ i "circle-check-big" "w-4 h-4" }}
199
213
<span class="font-medium">no conflicts, ready to merge</span>
···
201
215
</div>
202
216
{{ end }}
203
217
{{ end }}
218
+
219
+
{{ define "resubmitStatus" }}
220
+
{{ if .ResubmitCheck.Yes }}
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">
222
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
223
+
{{ i "triangle-alert" "w-4 h-4" }}
224
+
<span class="font-medium">this branch has been updated, consider resubmitting</span>
225
+
</div>
226
+
</div>
227
+
{{ end }}
228
+
{{ end }}
229
+
230
+
{{ define "commits" }}
231
+
{{ end }}
+41
-7
appview/pages/templates/repo/pulls/pulls.html
+41
-7
appview/pages/templates/repo/pulls/pulls.html
···
5
5
<p class="dark:text-white">
6
6
filtering
7
7
<select
8
-
class="border px-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white"
8
+
class="border p-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white"
9
9
onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value"
10
10
>
11
11
<option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}>
···
22
22
</p>
23
23
<a
24
24
href="/{{ .RepoInfo.FullName }}/pulls/new"
25
-
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:bg-gray-700 dark:hover:bg-gray-600"
25
+
class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"
26
26
>
27
-
{{ i "git-pull-request" "w-4 h-4" }}
28
-
<span>new pull request</span>
27
+
{{ i "git-pull-request-create" "w-4 h-4" }}
28
+
<span>new</span>
29
29
</a>
30
30
</div>
31
31
<div class="error" id="pulls"></div>
···
42
42
</a>
43
43
</div>
44
44
<p class="text-sm text-gray-500 dark:text-gray-400">
45
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
45
46
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
46
47
{{ $icon := "ban" }}
47
48
···
62
63
</span>
63
64
64
65
<span>
65
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
66
66
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
67
67
</span>
68
68
···
73
73
</span>
74
74
75
75
<span class="before:content-['ยท']">
76
-
targeting branch
77
-
<span class="text-xs rounded bg-gray-100 dark:bg-gray-600 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
76
+
targeting
77
+
<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">
78
78
{{ .TargetBranch }}
79
79
</span>
80
+
</span>
81
+
{{ if not .IsPatchBased }}
82
+
<span>from
83
+
{{ if .IsForkBased }}
84
+
{{ if .PullSource.Repo }}
85
+
<a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>
86
+
{{ else }}
87
+
<span class="italic">[deleted fork]</span>
88
+
{{ end }}
89
+
{{ end }}
90
+
91
+
<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">
92
+
{{ .PullSource.Branch }}
93
+
</span>
94
+
</span>
95
+
{{ end }}
96
+
<span class="before:content-['ยท']">
97
+
{{ $latestRound := .LastRoundNumber }}
98
+
{{ $lastSubmission := index .Submissions $latestRound }}
99
+
round
100
+
<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">
101
+
#{{ .LastRoundNumber }}
102
+
</span>
103
+
{{ $commentCount := len $lastSubmission.Comments }}
104
+
{{ $s := "s" }}
105
+
{{ if eq $commentCount 1 }}
106
+
{{ $s = "" }}
107
+
{{ end }}
108
+
109
+
{{ if eq $commentCount 0 }}
110
+
awaiting comments
111
+
{{ else }}
112
+
recieved {{ len $lastSubmission.Comments}} comment{{$s}}
113
+
{{ end }}
80
114
</span>
81
115
</p>
82
116
</div>
+50
-6
appview/pages/templates/repo/settings.html
+50
-6
appview/pages/templates/repo/settings.html
···
1
1
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
2
{{ define "repoContent" }}
3
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">Collaborators</header>
3
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
4
+
Collaborators
5
+
</header>
4
6
5
7
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
6
8
{{ range .Collaborators }}
···
20
22
{{ end }}
21
23
</div>
22
24
23
-
{{ if .IsCollaboratorInviteAllowed }}
24
-
<h3 class="dark:text-white">add collaborator</h3>
25
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
25
26
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
26
-
<label for="collaborator" class="dark:text-white">did or handle:</label>
27
-
<input type="text" id="collaborator" name="collaborator" required class="dark:bg-gray-700 dark:text-white" />
28
-
<button class="btn my-2 dark:text-white dark:hover:bg-gray-700" type="text">add collaborator</button>
27
+
<label for="collaborator" class="dark:text-white"
28
+
>add collaborator</label
29
+
>
30
+
<input
31
+
type="text"
32
+
id="collaborator"
33
+
name="collaborator"
34
+
required
35
+
class="dark:bg-gray-700 dark:text-white"
36
+
placeholder="enter did or handle"
37
+
/>
38
+
<button
39
+
class="btn my-2 dark:text-white dark:hover:bg-gray-700"
40
+
type="text"
41
+
>
42
+
add
43
+
</button>
29
44
</form>
30
45
{{ end }}
46
+
47
+
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6">
48
+
<label for="branch">default branch</label>
49
+
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
50
+
{{ range .Branches }}
51
+
<option
52
+
value="{{ . }}"
53
+
class="py-1"
54
+
{{ if eq . $.DefaultBranch }}
55
+
selected
56
+
{{ end }}
57
+
>
58
+
{{ . }}
59
+
</option>
60
+
{{ end }}
61
+
</select>
62
+
<button class="btn my-2" type="text">save</button>
63
+
</form>
64
+
65
+
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
66
+
<form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6">
67
+
<label for="branch">delete repository</label>
68
+
<button class="btn my-2" type="text">delete</button>
69
+
<span>
70
+
Deleting a repository is irreversible and permanent.
71
+
</span>
72
+
</form>
73
+
{{ end }}
74
+
31
75
{{ end }}
+7
-6
appview/pages/templates/repo/tree.html
+7
-6
appview/pages/templates/repo/tree.html
···
17
17
{{ $containerstyle := "py-1" }}
18
18
{{ $linkstyle := "no-underline hover:underline" }}
19
19
20
-
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-500">
20
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
21
21
<div class="flex flex-col md:flex-row md:justify-between gap-2">
22
-
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap">
22
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
23
23
{{ range .BreadCrumbs }}
24
-
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> /
24
+
<a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> /
25
25
{{ end }}
26
26
</div>
27
27
<div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
28
28
{{ $stats := .TreeStats }}
29
29
30
30
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
31
-
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
32
31
{{ if eq $stats.NumFolders 1 }}
33
-
<span>{{ $stats.NumFolders }} folder</span>
34
32
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
33
+
<span>{{ $stats.NumFolders }} folder</span>
35
34
{{ else if gt $stats.NumFolders 1 }}
36
-
<span>{{ $stats.NumFolders }} folders</span>
37
35
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
36
+
<span>{{ $stats.NumFolders }} folders</span>
38
37
{{ end }}
39
38
40
39
{{ if eq $stats.NumFiles 1 }}
40
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
41
41
<span>{{ $stats.NumFiles }} file</span>
42
42
{{ else if gt $stats.NumFiles 1 }}
43
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
43
44
<span>{{ $stats.NumFiles }} files</span>
44
45
{{ end }}
45
46
+12
-3
appview/pages/templates/timeline.html
+12
-3
appview/pages/templates/timeline.html
···
23
23
</div>
24
24
<div class="italic text-lg">
25
25
tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a>
26
-
<p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
26
+
<p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a>or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>.
27
27
Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p>
28
28
</div>
29
29
</div>
···
44
44
<div class="flex items-center">
45
45
<p class="text-gray-600 dark:text-gray-300">
46
46
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
47
-
created
48
-
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
47
+
{{ if .Source }}
48
+
forked
49
+
<a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">
50
+
{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}
51
+
</a>
52
+
to
53
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
54
+
{{ else }}
55
+
created
56
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
57
+
{{ end }}
49
58
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
50
59
</p>
51
60
</div>
+17
appview/pages/templates/user/fragments/follow.html
+17
appview/pages/templates/user/fragments/follow.html
···
1
+
{{ define "user/fragments/follow" }}
2
+
<button id="followBtn"
3
+
class="btn mt-2 w-full"
4
+
5
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}
6
+
hx-post="/follow?subject={{.UserDid}}"
7
+
{{ else }}
8
+
hx-delete="/follow?subject={{.UserDid}}"
9
+
{{ end }}
10
+
11
+
hx-trigger="click"
12
+
hx-target="#followBtn"
13
+
hx-swap="outerHTML"
14
+
>
15
+
{{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
16
+
</button>
17
+
{{ end }}
+11
-3
appview/pages/templates/user/login.html
+11
-3
appview/pages/templates/user/login.html
···
8
8
content="width=device-width, initial-scale=1.0"
9
9
/>
10
10
<script src="/static/htmx.min.js"></script>
11
-
<link rel="stylesheet" href="/static/tw.css" type="text/css" />
11
+
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
12
<title>login</title>
13
13
</head>
14
14
<body class="flex items-center justify-center min-h-screen">
···
27
27
>
28
28
<div class="flex flex-col">
29
29
<label for="handle">handle</label>
30
-
<input type="text" id="handle" name="handle" required />
30
+
<input
31
+
type="text"
32
+
id="handle"
33
+
name="handle"
34
+
tabindex="1"
35
+
required
36
+
/>
31
37
<span class="text-xs text-gray-500 mt-1">
32
38
You need to use your
33
39
<a href="https://bsky.app">Bluesky</a> handle to log
···
41
47
type="password"
42
48
id="app_password"
43
49
name="app_password"
50
+
tabindex="2"
44
51
required
45
52
/>
46
53
<span class="text-xs text-gray-500 mt-1">
···
57
64
class="btn w-full my-2 mt-6"
58
65
type="submit"
59
66
id="login-button"
67
+
tabindex="3"
60
68
>
61
69
<span>login</span>
62
70
</button>
63
71
</form>
64
72
<p class="text-sm text-gray-500">
65
-
Join our IRC channel:
73
+
Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel:
66
74
<a href="https://web.libera.chat/#tangled"
67
75
><code>#tangled</code> on Libera Chat</a
68
76
>.
+200
-56
appview/pages/templates/user/profile.html
+200
-56
appview/pages/templates/user/profile.html
···
9
9
{{ block "ownRepos" . }}{{ end }}
10
10
{{ block "collaboratingRepos" . }}{{ end }}
11
11
</div>
12
-
13
12
<div class="md:col-span-2 order-3 md:order-3">
14
13
{{ block "profileTimeline" . }}{{ end }}
15
14
</div>
16
15
</div>
17
16
{{ end }}
18
17
19
-
20
18
{{ define "profileTimeline" }}
21
-
<div class="flex flex-col gap-3 relative">
22
-
<p class="px-6 text-sm font-bold py-2 dark:text-white">ACTIVITY</p>
23
-
{{ range .ProfileTimeline }}
24
-
{{ if eq .Type "issue" }}
25
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit max-w-full flex items-center gap-2">
26
-
{{ $textColor := "text-gray-800 dark:text-gray-400" }}
27
-
{{ $icon := "ban" }}
28
-
{{ if .Issue.Open }}
29
-
{{ $textColor = "text-green-600 dark:text-green-500" }}
30
-
{{ $icon = "circle-dot" }}
31
-
{{ end }}
32
-
<div class="p-1 {{ $textColor }}">
33
-
{{ i $icon "w-5 h-5" }}
19
+
<p class="text-sm font-bold py-2 dark:text-white px-6">ACTIVITY</p>
20
+
<div class="flex flex-col gap-6 relative">
21
+
{{ with .ProfileTimeline }}
22
+
{{ range $idx, $byMonth := .ByMonth }}
23
+
{{ with $byMonth }}
24
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm">
25
+
{{ if eq $idx 0 }}
26
+
27
+
{{ else }}
28
+
{{ $s := "s" }}
29
+
{{ if eq $idx 1 }}
30
+
{{ $s = "" }}
31
+
{{ end }}
32
+
<p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p>
33
+
{{ end }}
34
+
35
+
{{ if .IsEmpty }}
36
+
<div class="text-gray-500 dark:text-gray-400">
37
+
No activity for this month
34
38
</div>
35
-
<div>
36
-
<p class="text-gray-600 dark:text-gray-300">
37
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/issues/{{ .Issue.IssueId }}" class="no-underline hover:underline">{{ .Issue.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span></a>
38
-
on
39
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a>
40
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Issue.Created | shortTimeFmt }}</time>
41
-
</p>
39
+
{{ else }}
40
+
<div class="flex flex-col gap-1">
41
+
{{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }}
42
+
{{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }}
43
+
{{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }}
42
44
</div>
45
+
{{ end }}
46
+
</div>
47
+
48
+
{{ end }}
49
+
{{ else }}
50
+
<p class="dark:text-white">This user does not have any activity yet.</p>
51
+
{{ end }}
52
+
{{ end }}
53
+
</div>
54
+
{{ end }}
55
+
56
+
{{ define "repoEvents" }}
57
+
{{ $items := index . 0 }}
58
+
{{ $handleMap := index . 1 }}
59
+
60
+
{{ if gt (len $items) 0 }}
61
+
<details>
62
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
63
+
<div class="flex flex-wrap items-center gap-2">
64
+
{{ i "book-plus" "w-4 h-4" }}
65
+
created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}}
66
+
</div>
67
+
</summary>
68
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
69
+
{{ range $items }}
70
+
<div class="flex flex-wrap items-center gap-2">
71
+
<span class="text-gray-500 dark:text-gray-400">
72
+
{{ if .Source }}
73
+
{{ i "git-fork" "w-4 h-4" }}
74
+
{{ else }}
75
+
{{ i "book-plus" "w-4 h-4" }}
76
+
{{ end }}
77
+
</span>
78
+
<a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
79
+
{{- .Repo.Name -}}
80
+
</a>
43
81
</div>
44
-
{{ else if eq .Type "pull" }}
45
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
46
-
{{ $textColor := "text-gray-800 dark:text-gray-400" }}
47
-
{{ $icon := "git-pull-request-closed" }}
48
-
{{ if .Pull.State.IsOpen }}
49
-
{{ $textColor = "text-green-600 dark:text-green-500" }}
50
-
{{ $icon = "git-pull-request" }}
51
-
{{ else if .Pull.State.IsMerged }}
52
-
{{ $textColor = "text-purple-600 dark:text-purple-500" }}
53
-
{{ $icon = "git-merge" }}
82
+
{{ end }}
83
+
</div>
84
+
</details>
85
+
{{ end }}
86
+
{{ end }}
87
+
88
+
{{ define "issueEvents" }}
89
+
{{ $i := index . 0 }}
90
+
{{ $items := $i.Items }}
91
+
{{ $stats := $i.Stats }}
92
+
{{ $handleMap := index . 1 }}
93
+
94
+
{{ if gt (len $items) 0 }}
95
+
<details>
96
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
97
+
<div class="flex flex-wrap items-center gap-2">
98
+
{{ i "circle-dot" "w-4 h-4" }}
99
+
100
+
<div>
101
+
created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}}
102
+
</div>
103
+
104
+
{{ if gt $stats.Open 0 }}
105
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
106
+
{{$stats.Open}} open
107
+
</span>
108
+
{{ end }}
109
+
110
+
{{ if gt $stats.Closed 0 }}
111
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
112
+
{{$stats.Closed}} closed
113
+
</span>
114
+
{{ end }}
115
+
116
+
</div>
117
+
</summary>
118
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
119
+
{{ range $items }}
120
+
{{ $repoOwner := index $handleMap .Metadata.Repo.Did }}
121
+
{{ $repoName := .Metadata.Repo.Name }}
122
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
123
+
124
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
125
+
{{ if .Open }}
126
+
<span class="text-green-600 dark:text-green-500">
127
+
{{ i "circle-dot" "w-4 h-4" }}
128
+
</span>
129
+
{{ else }}
130
+
<span class="text-gray-500 dark:text-gray-400">
131
+
{{ i "ban" "w-4 h-4" }}
132
+
</span>
54
133
{{ end }}
55
-
<div class="{{ $textColor }} p-1">
56
-
{{ i $icon "w-5 h-5" }}
134
+
<div class="flex-none min-w-8 text-right">
135
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
57
136
</div>
58
-
<div>
59
-
<p class="text-gray-600 dark:text-gray-300">
60
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}/pulls/{{ .Pull.PullId }}" class="no-underline hover:underline">{{ .Pull.Title }} <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span></a>
137
+
<div class="break-words max-w-full">
138
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
139
+
{{ .Title -}}
140
+
</a>
61
141
on
62
-
<a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">
63
-
{{ index $.DidHandleMap .Repo.Did }}<span class="select-none">/</span>{{ .Repo.Name }}</a>
64
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Pull.Created | shortTimeFmt }}</time>
65
-
</p>
142
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
143
+
{{$repoUrl}}
144
+
</a>
66
145
</div>
67
146
</div>
68
-
{{ else if eq .Type "repo" }}
69
-
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit flex items-center gap-3">
70
-
<div class="text-gray-800 dark:text-gray-400 p-1">
71
-
{{ i "book-plus" "w-5 h-5" }}
147
+
{{ end }}
148
+
</div>
149
+
</details>
150
+
{{ end }}
151
+
{{ end }}
152
+
153
+
{{ define "pullEvents" }}
154
+
{{ $i := index . 0 }}
155
+
{{ $items := $i.Items }}
156
+
{{ $stats := $i.Stats }}
157
+
{{ $handleMap := index . 1 }}
158
+
{{ if gt (len $items) 0 }}
159
+
<details>
160
+
<summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400">
161
+
<div class="flex flex-wrap items-center gap-2">
162
+
{{ i "git-pull-request" "w-4 h-4" }}
163
+
164
+
<div>
165
+
created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}}
166
+
</div>
167
+
168
+
{{ if gt $stats.Open 0 }}
169
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700">
170
+
{{$stats.Open}} open
171
+
</span>
172
+
{{ end }}
173
+
174
+
{{ if gt $stats.Merged 0 }}
175
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700">
176
+
{{$stats.Merged}} merged
177
+
</span>
178
+
{{ end }}
179
+
180
+
181
+
{{ if gt $stats.Closed 0 }}
182
+
<span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700">
183
+
{{$stats.Closed}} closed
184
+
</span>
185
+
{{ end }}
186
+
187
+
</div>
188
+
</summary>
189
+
<div class="py-2 text-sm flex flex-col gap-3 mb-2">
190
+
{{ range $items }}
191
+
{{ $repoOwner := index $handleMap .Repo.Did }}
192
+
{{ $repoName := .Repo.Name }}
193
+
{{ $repoUrl := printf "%s/%s" $repoOwner $repoName }}
194
+
195
+
<div class="flex gap-2 text-gray-600 dark:text-gray-300">
196
+
{{ if .State.IsOpen }}
197
+
<span class="text-green-600 dark:text-green-500">
198
+
{{ i "git-pull-request" "w-4 h-4" }}
199
+
</span>
200
+
{{ else if .State.IsMerged }}
201
+
<span class="text-purple-600 dark:text-purple-500">
202
+
{{ i "git-merge" "w-4 h-4" }}
203
+
</span>
204
+
{{ else }}
205
+
<span class="text-gray-600 dark:text-gray-300">
206
+
{{ i "git-pull-request-closed" "w-4 h-4" }}
207
+
</span>
208
+
{{ end }}
209
+
<div class="flex-none min-w-8 text-right">
210
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
72
211
</div>
73
-
<div>
74
-
<p class="text-gray-600 dark:text-gray-300">
75
-
created <a href="/{{ index $.DidHandleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
76
-
<time class="text-gray-700 dark:text-gray-400 text-xs ml-2">{{ .Repo.Created | shortTimeFmt }}</time>
77
-
</p>
212
+
<div class="break-words max-w-full">
213
+
<a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline">
214
+
{{ .Title -}}
215
+
</a>
216
+
on
217
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
218
+
{{$repoUrl}}
219
+
</a>
78
220
</div>
79
221
</div>
80
-
{{ end }}
81
-
{{ else }}
82
-
<p class="px-6 dark:text-white">This user does not have any activity yet.</p>
83
222
{{ end }}
84
223
</div>
224
+
</details>
225
+
{{ end }}
85
226
{{ end }}
86
227
87
228
{{ define "profileCard" }}
···
91
232
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
92
233
{{ end }}
93
234
</div>
94
-
<p class="text-xl font-bold text-center dark:text-white">
95
-
{{ truncateAt30 (didOrHandle .UserDid .UserHandle) }}
235
+
<p
236
+
title="{{ didOrHandle .UserDid .UserHandle }}"
237
+
class="text-lg font-bold text-center dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"
238
+
>
239
+
{{ didOrHandle .UserDid .UserHandle }}
96
240
</p>
97
241
<div class="text-sm text-center dark:text-gray-300">
98
242
<span>{{ .ProfileStats.Followers }} followers</span>
···
103
247
</div>
104
248
105
249
{{ if ne .FollowStatus.String "IsSelf" }}
106
-
{{ template "fragments/follow" . }}
250
+
{{ template "user/fragments/follow" . }}
107
251
{{ end }}
108
252
</div>
109
253
{{ 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,
+16
-93
appview/state/middleware.go
+16
-93
appview/state/middleware.go
···
8
8
"strings"
9
9
"time"
10
10
11
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"slices"
12
+
12
13
"github.com/bluesky-social/indigo/atproto/identity"
13
-
"github.com/bluesky-social/indigo/xrpc"
14
14
"github.com/go-chi/chi/v5"
15
-
"tangled.sh/tangled.sh/core/appview"
16
-
"tangled.sh/tangled.sh/core/appview/auth"
17
15
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
18
17
)
19
18
20
-
type Middleware func(http.Handler) http.Handler
21
-
22
-
func AuthMiddleware(s *State) Middleware {
23
-
return func(next http.Handler) http.Handler {
24
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25
-
redirectFunc := func(w http.ResponseWriter, r *http.Request) {
26
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
27
-
}
28
-
if r.Header.Get("HX-Request") == "true" {
29
-
redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
30
-
w.Header().Set("HX-Redirect", "/login")
31
-
w.WriteHeader(http.StatusOK)
32
-
}
33
-
}
34
-
35
-
session, err := s.auth.GetSession(r)
36
-
if session.IsNew || err != nil {
37
-
log.Printf("not logged in, redirecting")
38
-
redirectFunc(w, r)
39
-
return
40
-
}
41
-
42
-
authorized, ok := session.Values[appview.SessionAuthenticated].(bool)
43
-
if !ok || !authorized {
44
-
log.Printf("not logged in, redirecting")
45
-
redirectFunc(w, r)
46
-
return
47
-
}
48
-
49
-
// refresh if nearing expiry
50
-
// TODO: dedup with /login
51
-
expiryStr := session.Values[appview.SessionExpiry].(string)
52
-
expiry, err := time.Parse(time.RFC3339, expiryStr)
53
-
if err != nil {
54
-
log.Println("invalid expiry time", err)
55
-
redirectFunc(w, r)
56
-
return
57
-
}
58
-
pdsUrl, ok1 := session.Values[appview.SessionPds].(string)
59
-
did, ok2 := session.Values[appview.SessionDid].(string)
60
-
refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string)
61
-
62
-
if !ok1 || !ok2 || !ok3 {
63
-
log.Println("invalid expiry time", err)
64
-
redirectFunc(w, r)
65
-
return
66
-
}
67
-
68
-
if time.Now().After(expiry) {
69
-
log.Println("token expired, refreshing ...")
70
-
71
-
client := xrpc.Client{
72
-
Host: pdsUrl,
73
-
Auth: &xrpc.AuthInfo{
74
-
Did: did,
75
-
AccessJwt: refreshJwt,
76
-
RefreshJwt: refreshJwt,
77
-
},
78
-
}
79
-
atSession, err := comatproto.ServerRefreshSession(r.Context(), &client)
80
-
if err != nil {
81
-
log.Println("failed to refresh session", err)
82
-
redirectFunc(w, r)
83
-
return
84
-
}
85
-
86
-
sessionish := auth.RefreshSessionWrapper{atSession}
87
-
88
-
err = s.auth.StoreSession(r, w, &sessionish, pdsUrl)
89
-
if err != nil {
90
-
log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err)
91
-
return
92
-
}
93
-
94
-
log.Println("successfully refreshed token")
95
-
}
96
-
97
-
next.ServeHTTP(w, r)
98
-
})
99
-
}
100
-
}
101
-
102
-
func knotRoleMiddleware(s *State, group string) Middleware {
19
+
func knotRoleMiddleware(s *State, group string) middleware.Middleware {
103
20
return func(next http.Handler) http.Handler {
104
21
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105
22
// requires auth also
···
129
46
}
130
47
}
131
48
132
-
func KnotOwner(s *State) Middleware {
49
+
func KnotOwner(s *State) middleware.Middleware {
133
50
return knotRoleMiddleware(s, "server:owner")
134
51
}
135
52
136
-
func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware {
53
+
func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware {
137
54
return func(next http.Handler) http.Handler {
138
55
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139
56
// requires auth also
···
150
67
return
151
68
}
152
69
153
-
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm)
70
+
ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
154
71
if err != nil || !ok {
155
72
// we need a logged in user
156
73
log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
···
173
90
})
174
91
}
175
92
176
-
func ResolveIdent(s *State) Middleware {
93
+
func ResolveIdent(s *State) middleware.Middleware {
94
+
excluded := []string{"favicon.ico"}
95
+
177
96
return func(next http.Handler) http.Handler {
178
97
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
179
98
didOrHandle := chi.URLParam(req, "user")
99
+
if slices.Contains(excluded, didOrHandle) {
100
+
next.ServeHTTP(w, req)
101
+
return
102
+
}
180
103
181
104
id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle)
182
105
if err != nil {
···
193
116
}
194
117
}
195
118
196
-
func ResolveRepo(s *State) Middleware {
119
+
func ResolveRepo(s *State) middleware.Middleware {
197
120
return func(next http.Handler) http.Handler {
198
121
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
199
122
repoName := chi.URLParam(req, "repo")
···
222
145
}
223
146
224
147
// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
225
-
func ResolvePull(s *State) Middleware {
148
+
func ResolvePull(s *State) middleware.Middleware {
226
149
return func(next http.Handler) http.Handler {
227
150
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
228
151
f, err := fullyResolvedRepo(r)
+17
-6
appview/state/profile.go
+17
-6
appview/state/profile.go
···
5
5
"log"
6
6
"net/http"
7
7
8
+
"github.com/bluesky-social/indigo/atproto/identity"
8
9
"github.com/go-chi/chi/v5"
9
10
"tangled.sh/tangled.sh/core/appview/db"
10
11
"tangled.sh/tangled.sh/core/appview/pages"
···
17
18
return
18
19
}
19
20
20
-
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
21
-
if err != nil {
22
-
log.Printf("resolving identity: %s", err)
23
-
w.WriteHeader(http.StatusNotFound)
21
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
22
+
if !ok {
23
+
s.pages.Error404(w)
24
24
return
25
25
}
26
26
···
43
43
for _, r := range collaboratingRepos {
44
44
didsToResolve = append(didsToResolve, r.Did)
45
45
}
46
-
for _, evt := range timeline {
47
-
didsToResolve = append(didsToResolve, evt.Repo.Did)
46
+
for _, byMonth := range timeline.ByMonth {
47
+
for _, pe := range byMonth.PullEvents.Items {
48
+
didsToResolve = append(didsToResolve, pe.Repo.Did)
49
+
}
50
+
for _, ie := range byMonth.IssueEvents.Items {
51
+
didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did)
52
+
}
53
+
for _, re := range byMonth.RepoEvents {
54
+
didsToResolve = append(didsToResolve, re.Repo.Did)
55
+
if re.Source != nil {
56
+
didsToResolve = append(didsToResolve, re.Source.Did)
57
+
}
58
+
}
48
59
}
49
60
50
61
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
+967
-144
appview/state/pull.go
+967
-144
appview/state/pull.go
···
1
1
package state
2
2
3
3
import (
4
+
"database/sql"
4
5
"encoding/json"
6
+
"errors"
5
7
"fmt"
6
8
"io"
7
9
"log"
8
10
"net/http"
11
+
"net/url"
9
12
"strconv"
10
-
"strings"
11
13
"time"
12
14
13
-
"github.com/go-chi/chi/v5"
14
15
"tangled.sh/tangled.sh/core/api/tangled"
16
+
"tangled.sh/tangled.sh/core/appview"
17
+
"tangled.sh/tangled.sh/core/appview/auth"
15
18
"tangled.sh/tangled.sh/core/appview/db"
16
19
"tangled.sh/tangled.sh/core/appview/pages"
20
+
"tangled.sh/tangled.sh/core/patchutil"
17
21
"tangled.sh/tangled.sh/core/types"
18
22
19
23
comatproto "github.com/bluesky-social/indigo/api/atproto"
24
+
"github.com/bluesky-social/indigo/atproto/syntax"
20
25
lexutil "github.com/bluesky-social/indigo/lex/util"
26
+
"github.com/go-chi/chi/v5"
21
27
)
22
28
23
29
// htmx fragment
···
50
56
}
51
57
52
58
mergeCheckResponse := s.mergeCheck(f, pull)
59
+
resubmitResult := pages.Unknown
60
+
if user.Did == pull.OwnerDid {
61
+
resubmitResult = s.resubmitCheck(f, pull)
62
+
}
53
63
54
64
s.pages.PullActionsFragment(w, pages.PullActionsParams{
55
-
LoggedInUser: user,
56
-
RepoInfo: f.RepoInfo(s, user),
57
-
Pull: pull,
58
-
RoundNumber: roundNumber,
59
-
MergeCheck: mergeCheckResponse,
65
+
LoggedInUser: user,
66
+
RepoInfo: f.RepoInfo(s, user),
67
+
Pull: pull,
68
+
RoundNumber: roundNumber,
69
+
MergeCheck: mergeCheckResponse,
70
+
ResubmitCheck: resubmitResult,
60
71
})
61
72
return
62
73
}
···
105
116
}
106
117
107
118
mergeCheckResponse := s.mergeCheck(f, pull)
119
+
resubmitResult := pages.Unknown
120
+
if user != nil && user.Did == pull.OwnerDid {
121
+
resubmitResult = s.resubmitCheck(f, pull)
122
+
}
108
123
109
124
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
110
-
LoggedInUser: user,
111
-
RepoInfo: f.RepoInfo(s, user),
112
-
DidHandleMap: didHandleMap,
113
-
Pull: *pull,
114
-
MergeCheck: mergeCheckResponse,
125
+
LoggedInUser: user,
126
+
RepoInfo: f.RepoInfo(s, user),
127
+
DidHandleMap: didHandleMap,
128
+
Pull: pull,
129
+
MergeCheck: mergeCheckResponse,
130
+
ResubmitCheck: resubmitResult,
115
131
})
116
132
}
117
133
···
175
191
return mergeCheckResponse
176
192
}
177
193
194
+
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
195
+
if pull.State == db.PullMerged || pull.PullSource == nil {
196
+
return pages.Unknown
197
+
}
198
+
199
+
var knot, ownerDid, repoName string
200
+
201
+
if pull.PullSource.RepoAt != nil {
202
+
// fork-based pulls
203
+
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
204
+
if err != nil {
205
+
log.Println("failed to get source repo", err)
206
+
return pages.Unknown
207
+
}
208
+
209
+
knot = sourceRepo.Knot
210
+
ownerDid = sourceRepo.Did
211
+
repoName = sourceRepo.Name
212
+
} else {
213
+
// pulls within the same repo
214
+
knot = f.Knot
215
+
ownerDid = f.OwnerDid()
216
+
repoName = f.RepoName
217
+
}
218
+
219
+
us, err := NewUnsignedClient(knot, s.config.Dev)
220
+
if err != nil {
221
+
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
222
+
return pages.Unknown
223
+
}
224
+
225
+
resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
226
+
if err != nil {
227
+
log.Println("failed to reach knotserver", err)
228
+
return pages.Unknown
229
+
}
230
+
231
+
body, err := io.ReadAll(resp.Body)
232
+
if err != nil {
233
+
log.Printf("error reading response body: %v", err)
234
+
return pages.Unknown
235
+
}
236
+
defer resp.Body.Close()
237
+
238
+
var result types.RepoBranchResponse
239
+
if err := json.Unmarshal(body, &result); err != nil {
240
+
log.Println("failed to parse response:", err)
241
+
return pages.Unknown
242
+
}
243
+
244
+
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
245
+
if latestSubmission.SourceRev != result.Branch.Hash {
246
+
fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
247
+
return pages.ShouldResubmit
248
+
}
249
+
250
+
return pages.ShouldNotResubmit
251
+
}
252
+
178
253
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
179
254
user := s.auth.GetUser(r)
180
255
f, err := fullyResolvedRepo(r)
···
209
284
}
210
285
}
211
286
287
+
diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
288
+
212
289
s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
213
290
LoggedInUser: user,
214
291
DidHandleMap: didHandleMap,
···
216
293
Pull: pull,
217
294
Round: roundIdInt,
218
295
Submission: pull.Submissions[roundIdInt],
219
-
Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
296
+
Diff: &diff,
220
297
})
221
298
222
299
}
223
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
+
224
369
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
225
370
pull, ok := r.Context().Value("pull").(*db.Pull)
226
371
if !ok {
···
277
422
return
278
423
}
279
424
425
+
for _, p := range pulls {
426
+
var pullSourceRepo *db.Repo
427
+
if p.PullSource != nil {
428
+
if p.PullSource.RepoAt != nil {
429
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
430
+
if err != nil {
431
+
log.Printf("failed to get repo by at uri: %v", err)
432
+
continue
433
+
} else {
434
+
p.PullSource.Repo = pullSourceRepo
435
+
}
436
+
}
437
+
}
438
+
}
439
+
280
440
identsToResolve := make([]string, len(pulls))
281
441
for i, pull := range pulls {
282
442
identsToResolve[i] = pull.OwnerDid
···
364
524
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
365
525
Collection: tangled.RepoPullCommentNSID,
366
526
Repo: user.Did,
367
-
Rkey: s.TID(),
527
+
Rkey: appview.TID(),
368
528
Record: &lexutil.LexiconTypeDecoder{
369
529
Val: &tangled.RepoPullComment{
370
530
Repo: &atUri,
···
453
613
title := r.FormValue("title")
454
614
body := r.FormValue("body")
455
615
targetBranch := r.FormValue("targetBranch")
616
+
fromFork := r.FormValue("fork")
617
+
sourceBranch := r.FormValue("sourceBranch")
456
618
patch := r.FormValue("patch")
457
619
458
-
if title == "" || body == "" || patch == "" || targetBranch == "" {
459
-
s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
620
+
if targetBranch == "" {
621
+
s.pages.Notice(w, "pull", "Target branch is required.")
460
622
return
461
623
}
462
624
463
-
// Validate patch format
464
-
if !isPatchValid(patch) {
465
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
625
+
// Determine PR type based on input parameters
626
+
isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
627
+
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
628
+
isForkBased := fromFork != "" && sourceBranch != ""
629
+
isPatchBased := patch != "" && !isBranchBased && !isForkBased
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
+
638
+
// Validate we have at least one valid PR creation method
639
+
if !isBranchBased && !isPatchBased && !isForkBased {
640
+
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
466
641
return
467
642
}
468
643
469
-
tx, err := s.db.BeginTx(r.Context(), nil)
470
-
if err != nil {
471
-
log.Println("failed to start tx")
472
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
644
+
// Can't mix branch-based and patch-based approaches
645
+
if isBranchBased && patch != "" {
646
+
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
473
647
return
474
648
}
475
-
defer tx.Rollback()
476
649
477
-
rkey := s.TID()
478
-
initialSubmission := db.PullSubmission{
479
-
Patch: patch,
480
-
}
481
-
err = db.NewPull(tx, &db.Pull{
482
-
Title: title,
483
-
Body: body,
484
-
TargetBranch: targetBranch,
485
-
OwnerDid: user.Did,
486
-
RepoAt: f.RepoAt,
487
-
Rkey: rkey,
488
-
Submissions: []*db.PullSubmission{
489
-
&initialSubmission,
490
-
},
491
-
})
650
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
492
651
if err != nil {
493
-
log.Println("failed to create pull request", err)
494
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
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.")
495
654
return
496
655
}
497
-
client, _ := s.auth.AuthorizedClient(r)
498
-
pullId, err := db.NextPullId(s.db, f.RepoAt)
656
+
657
+
caps, err := us.Capabilities()
499
658
if err != nil {
500
-
log.Println("failed to get pull id", err)
501
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
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.")
502
661
return
503
662
}
504
663
505
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
506
-
Collection: tangled.RepoPullNSID,
507
-
Repo: user.Did,
508
-
Rkey: rkey,
509
-
Record: &lexutil.LexiconTypeDecoder{
510
-
Val: &tangled.RepoPull{
511
-
Title: title,
512
-
PullId: int64(pullId),
513
-
TargetRepo: string(f.RepoAt),
514
-
TargetBranch: targetBranch,
515
-
Patch: patch,
516
-
},
517
-
},
518
-
})
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
+
}
519
668
520
-
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
669
+
// Handle the PR creation based on the type
670
+
if isBranchBased {
671
+
if !caps.PullRequests.BranchSubmissions {
672
+
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
673
+
return
674
+
}
675
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
676
+
} else if isForkBased {
677
+
if !caps.PullRequests.ForkSubmissions {
678
+
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
679
+
return
680
+
}
681
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
682
+
} else if isPatchBased {
683
+
if !caps.PullRequests.PatchSubmissions {
684
+
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
685
+
return
686
+
}
687
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
688
+
}
689
+
return
690
+
}
691
+
}
692
+
693
+
func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
694
+
pullSource := &db.PullSource{
695
+
Branch: sourceBranch,
696
+
}
697
+
recordPullSource := &tangled.RepoPull_Source{
698
+
Branch: sourceBranch,
699
+
}
700
+
701
+
// Generate a patch using /compare
702
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
703
+
if err != nil {
704
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
705
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
706
+
return
707
+
}
708
+
709
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
710
+
if err != nil {
711
+
log.Println("failed to compare", err)
712
+
s.pages.Notice(w, "pull", err.Error())
713
+
return
714
+
}
715
+
716
+
sourceRev := comparison.Rev2
717
+
patch := comparison.Patch
718
+
719
+
if !patchutil.IsPatchValid(patch) {
720
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
721
+
return
722
+
}
723
+
724
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
725
+
}
726
+
727
+
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
728
+
if !patchutil.IsPatchValid(patch) {
729
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
730
+
return
731
+
}
732
+
733
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
734
+
}
735
+
736
+
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
737
+
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
738
+
if errors.Is(err, sql.ErrNoRows) {
739
+
s.pages.Notice(w, "pull", "No such fork.")
740
+
return
741
+
} else if err != nil {
742
+
log.Println("failed to fetch fork:", err)
743
+
s.pages.Notice(w, "pull", "Failed to fetch fork.")
744
+
return
745
+
}
746
+
747
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
748
+
if err != nil {
749
+
log.Println("failed to fetch registration key:", err)
750
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
751
+
return
752
+
}
753
+
754
+
sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
755
+
if err != nil {
756
+
log.Println("failed to create signed client:", err)
757
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
758
+
return
759
+
}
760
+
761
+
us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
762
+
if err != nil {
763
+
log.Println("failed to create unsigned client:", err)
764
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
765
+
return
766
+
}
767
+
768
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
769
+
if err != nil {
770
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
771
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
772
+
return
773
+
}
774
+
775
+
switch resp.StatusCode {
776
+
case 404:
777
+
case 400:
778
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
779
+
return
780
+
}
781
+
782
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
783
+
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
784
+
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
785
+
// hiddenRef: hidden/feature-1/main (on repo-fork)
786
+
// targetBranch: main (on repo-1)
787
+
// sourceBranch: feature-1 (on repo-fork)
788
+
comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
789
+
if err != nil {
790
+
log.Println("failed to compare across branches", err)
791
+
s.pages.Notice(w, "pull", err.Error())
792
+
return
793
+
}
794
+
795
+
sourceRev := comparison.Rev2
796
+
patch := comparison.Patch
797
+
798
+
if !patchutil.IsPatchValid(patch) {
799
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
800
+
return
801
+
}
802
+
803
+
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
804
+
if err != nil {
805
+
log.Println("failed to parse fork AT URI", err)
806
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
807
+
return
808
+
}
809
+
810
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
811
+
Branch: sourceBranch,
812
+
RepoAt: &forkAtUri,
813
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
814
+
}
815
+
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
+
) {
827
+
tx, err := s.db.BeginTx(r.Context(), nil)
828
+
if err != nil {
829
+
log.Println("failed to start tx")
830
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
831
+
return
832
+
}
833
+
defer tx.Rollback()
834
+
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)
521
839
if err != nil {
522
-
log.Println("failed to get pull id", err)
523
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
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.")
524
845
return
525
846
}
526
847
527
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
848
+
title = formatPatches[0].Title
849
+
body = formatPatches[0].Body
850
+
}
851
+
852
+
rkey := appview.TID()
853
+
initialSubmission := db.PullSubmission{
854
+
Patch: patch,
855
+
SourceRev: sourceRev,
856
+
}
857
+
err = db.NewPull(tx, &db.Pull{
858
+
Title: title,
859
+
Body: body,
860
+
TargetBranch: targetBranch,
861
+
OwnerDid: user.Did,
862
+
RepoAt: f.RepoAt,
863
+
Rkey: rkey,
864
+
Submissions: []*db.PullSubmission{
865
+
&initialSubmission,
866
+
},
867
+
PullSource: pullSource,
868
+
})
869
+
if err != nil {
870
+
log.Println("failed to create pull request", err)
871
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
872
+
return
873
+
}
874
+
client, _ := s.auth.AuthorizedClient(r)
875
+
pullId, err := db.NextPullId(s.db, f.RepoAt)
876
+
if err != nil {
877
+
log.Println("failed to get pull id", err)
878
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
879
+
return
880
+
}
881
+
882
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
883
+
Collection: tangled.RepoPullNSID,
884
+
Repo: user.Did,
885
+
Rkey: rkey,
886
+
Record: &lexutil.LexiconTypeDecoder{
887
+
Val: &tangled.RepoPull{
888
+
Title: title,
889
+
PullId: int64(pullId),
890
+
TargetRepo: string(f.RepoAt),
891
+
TargetBranch: targetBranch,
892
+
Patch: patch,
893
+
Source: recordPullSource,
894
+
},
895
+
},
896
+
})
897
+
898
+
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
899
+
if err != nil {
900
+
log.Println("failed to get pull id", err)
901
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
902
+
return
903
+
}
904
+
905
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
906
+
}
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
+
933
+
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
934
+
user := s.auth.GetUser(r)
935
+
f, err := fullyResolvedRepo(r)
936
+
if err != nil {
937
+
log.Println("failed to get repo and knot", err)
938
+
return
939
+
}
940
+
941
+
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
942
+
RepoInfo: f.RepoInfo(s, user),
943
+
})
944
+
}
945
+
946
+
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
947
+
user := s.auth.GetUser(r)
948
+
f, err := fullyResolvedRepo(r)
949
+
if err != nil {
950
+
log.Println("failed to get repo and knot", err)
951
+
return
952
+
}
953
+
954
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
955
+
if err != nil {
956
+
log.Printf("failed to create unsigned client for %s", f.Knot)
957
+
s.pages.Error503(w)
958
+
return
959
+
}
960
+
961
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
962
+
if err != nil {
963
+
log.Println("failed to reach knotserver", err)
964
+
return
965
+
}
966
+
967
+
body, err := io.ReadAll(resp.Body)
968
+
if err != nil {
969
+
log.Printf("Error reading response body: %v", err)
970
+
return
971
+
}
972
+
973
+
var result types.RepoBranchesResponse
974
+
err = json.Unmarshal(body, &result)
975
+
if err != nil {
976
+
log.Println("failed to parse response:", err)
977
+
return
978
+
}
979
+
980
+
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
981
+
RepoInfo: f.RepoInfo(s, user),
982
+
Branches: result.Branches,
983
+
})
984
+
}
985
+
986
+
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
987
+
user := s.auth.GetUser(r)
988
+
f, err := fullyResolvedRepo(r)
989
+
if err != nil {
990
+
log.Println("failed to get repo and knot", err)
991
+
return
992
+
}
993
+
994
+
forks, err := db.GetForksByDid(s.db, user.Did)
995
+
if err != nil {
996
+
log.Println("failed to get forks", err)
997
+
return
998
+
}
999
+
1000
+
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1001
+
RepoInfo: f.RepoInfo(s, user),
1002
+
Forks: forks,
1003
+
})
1004
+
}
1005
+
1006
+
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1007
+
user := s.auth.GetUser(r)
1008
+
1009
+
f, err := fullyResolvedRepo(r)
1010
+
if err != nil {
1011
+
log.Println("failed to get repo and knot", err)
1012
+
return
1013
+
}
1014
+
1015
+
forkVal := r.URL.Query().Get("fork")
1016
+
1017
+
// fork repo
1018
+
repo, err := db.GetRepo(s.db, user.Did, forkVal)
1019
+
if err != nil {
1020
+
log.Println("failed to get repo", user.Did, forkVal)
1021
+
return
1022
+
}
1023
+
1024
+
sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1025
+
if err != nil {
1026
+
log.Printf("failed to create unsigned client for %s", repo.Knot)
1027
+
s.pages.Error503(w)
1028
+
return
1029
+
}
1030
+
1031
+
sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1032
+
if err != nil {
1033
+
log.Println("failed to reach knotserver for source branches", err)
1034
+
return
1035
+
}
1036
+
1037
+
sourceBody, err := io.ReadAll(sourceResp.Body)
1038
+
if err != nil {
1039
+
log.Println("failed to read source response body", err)
1040
+
return
1041
+
}
1042
+
defer sourceResp.Body.Close()
1043
+
1044
+
var sourceResult types.RepoBranchesResponse
1045
+
err = json.Unmarshal(sourceBody, &sourceResult)
1046
+
if err != nil {
1047
+
log.Println("failed to parse source branches response:", err)
1048
+
return
1049
+
}
1050
+
1051
+
targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1052
+
if err != nil {
1053
+
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1054
+
s.pages.Error503(w)
1055
+
return
1056
+
}
1057
+
1058
+
targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1059
+
if err != nil {
1060
+
log.Println("failed to reach knotserver for target branches", err)
1061
+
return
1062
+
}
1063
+
1064
+
targetBody, err := io.ReadAll(targetResp.Body)
1065
+
if err != nil {
1066
+
log.Println("failed to read target response body", err)
1067
+
return
1068
+
}
1069
+
defer targetResp.Body.Close()
1070
+
1071
+
var targetResult types.RepoBranchesResponse
1072
+
err = json.Unmarshal(targetBody, &targetResult)
1073
+
if err != nil {
1074
+
log.Println("failed to parse target branches response:", err)
528
1075
return
529
1076
}
1077
+
1078
+
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1079
+
RepoInfo: f.RepoInfo(s, user),
1080
+
SourceBranches: sourceResult.Branches,
1081
+
TargetBranches: targetResult.Branches,
1082
+
})
530
1083
}
531
1084
532
1085
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
···
552
1105
})
553
1106
return
554
1107
case http.MethodPost:
555
-
patch := r.FormValue("patch")
556
-
557
-
if patch == "" {
558
-
s.pages.Notice(w, "resubmit-error", "Patch is empty.")
1108
+
if pull.IsPatchBased() {
1109
+
s.resubmitPatch(w, r)
559
1110
return
560
-
}
561
-
562
-
if patch == pull.LatestPatch() {
563
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1111
+
} else if pull.IsBranchBased() {
1112
+
s.resubmitBranch(w, r)
1113
+
return
1114
+
} else if pull.IsForkBased() {
1115
+
s.resubmitFork(w, r)
564
1116
return
565
1117
}
1118
+
}
1119
+
}
566
1120
567
-
// Validate patch format
568
-
if !isPatchValid(patch) {
569
-
s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
570
-
return
571
-
}
1121
+
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1122
+
user := s.auth.GetUser(r)
1123
+
1124
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1125
+
if !ok {
1126
+
log.Println("failed to get pull")
1127
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1128
+
return
1129
+
}
1130
+
1131
+
f, err := fullyResolvedRepo(r)
1132
+
if err != nil {
1133
+
log.Println("failed to get repo and knot", err)
1134
+
return
1135
+
}
1136
+
1137
+
if user.Did != pull.OwnerDid {
1138
+
log.Println("unauthorized user")
1139
+
w.WriteHeader(http.StatusUnauthorized)
1140
+
return
1141
+
}
1142
+
1143
+
patch := r.FormValue("patch")
1144
+
1145
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1146
+
s.pages.Notice(w, "resubmit-error", err.Error())
1147
+
return
1148
+
}
1149
+
1150
+
tx, err := s.db.BeginTx(r.Context(), nil)
1151
+
if err != nil {
1152
+
log.Println("failed to start tx")
1153
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1154
+
return
1155
+
}
1156
+
defer tx.Rollback()
1157
+
1158
+
err = db.ResubmitPull(tx, pull, patch, "")
1159
+
if err != nil {
1160
+
log.Println("failed to resubmit pull request", err)
1161
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1162
+
return
1163
+
}
1164
+
client, _ := s.auth.AuthorizedClient(r)
1165
+
1166
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1167
+
if err != nil {
1168
+
// failed to get record
1169
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1170
+
return
1171
+
}
1172
+
1173
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1174
+
Collection: tangled.RepoPullNSID,
1175
+
Repo: user.Did,
1176
+
Rkey: pull.Rkey,
1177
+
SwapRecord: ex.Cid,
1178
+
Record: &lexutil.LexiconTypeDecoder{
1179
+
Val: &tangled.RepoPull{
1180
+
Title: pull.Title,
1181
+
PullId: int64(pull.PullId),
1182
+
TargetRepo: string(f.RepoAt),
1183
+
TargetBranch: pull.TargetBranch,
1184
+
Patch: patch, // new patch
1185
+
},
1186
+
},
1187
+
})
1188
+
if err != nil {
1189
+
log.Println("failed to update record", err)
1190
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1191
+
return
1192
+
}
1193
+
1194
+
if err = tx.Commit(); err != nil {
1195
+
log.Println("failed to commit transaction", err)
1196
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1197
+
return
1198
+
}
1199
+
1200
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1201
+
return
1202
+
}
1203
+
1204
+
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1205
+
user := s.auth.GetUser(r)
1206
+
1207
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1208
+
if !ok {
1209
+
log.Println("failed to get pull")
1210
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1211
+
return
1212
+
}
1213
+
1214
+
f, err := fullyResolvedRepo(r)
1215
+
if err != nil {
1216
+
log.Println("failed to get repo and knot", err)
1217
+
return
1218
+
}
1219
+
1220
+
if user.Did != pull.OwnerDid {
1221
+
log.Println("unauthorized user")
1222
+
w.WriteHeader(http.StatusUnauthorized)
1223
+
return
1224
+
}
1225
+
1226
+
if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1227
+
log.Println("unauthorized user")
1228
+
w.WriteHeader(http.StatusUnauthorized)
1229
+
return
1230
+
}
1231
+
1232
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1233
+
if err != nil {
1234
+
log.Printf("failed to create client for %s: %s", f.Knot, err)
1235
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1236
+
return
1237
+
}
1238
+
1239
+
comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1240
+
if err != nil {
1241
+
log.Printf("compare request failed: %s", err)
1242
+
s.pages.Notice(w, "resubmit-error", err.Error())
1243
+
return
1244
+
}
1245
+
1246
+
sourceRev := comparison.Rev2
1247
+
patch := comparison.Patch
1248
+
1249
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1250
+
s.pages.Notice(w, "resubmit-error", err.Error())
1251
+
return
1252
+
}
1253
+
1254
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1255
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1256
+
return
1257
+
}
572
1258
573
-
tx, err := s.db.BeginTx(r.Context(), nil)
574
-
if err != nil {
575
-
log.Println("failed to start tx")
576
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
577
-
return
578
-
}
579
-
defer tx.Rollback()
1259
+
tx, err := s.db.BeginTx(r.Context(), nil)
1260
+
if err != nil {
1261
+
log.Println("failed to start tx")
1262
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1263
+
return
1264
+
}
1265
+
defer tx.Rollback()
580
1266
581
-
err = db.ResubmitPull(tx, pull, patch)
582
-
if err != nil {
583
-
log.Println("failed to create pull request", err)
584
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
585
-
return
586
-
}
587
-
client, _ := s.auth.AuthorizedClient(r)
1267
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1268
+
if err != nil {
1269
+
log.Println("failed to create pull request", err)
1270
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1271
+
return
1272
+
}
1273
+
client, _ := s.auth.AuthorizedClient(r)
588
1274
589
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
590
-
if err != nil {
591
-
// failed to get record
592
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
593
-
return
594
-
}
1275
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1276
+
if err != nil {
1277
+
// failed to get record
1278
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1279
+
return
1280
+
}
595
1281
596
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
597
-
Collection: tangled.RepoPullNSID,
598
-
Repo: user.Did,
599
-
Rkey: pull.Rkey,
600
-
SwapRecord: ex.Cid,
601
-
Record: &lexutil.LexiconTypeDecoder{
602
-
Val: &tangled.RepoPull{
603
-
Title: pull.Title,
604
-
PullId: int64(pull.PullId),
605
-
TargetRepo: string(f.RepoAt),
606
-
TargetBranch: pull.TargetBranch,
607
-
Patch: patch, // new patch
608
-
},
1282
+
recordPullSource := &tangled.RepoPull_Source{
1283
+
Branch: pull.PullSource.Branch,
1284
+
}
1285
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1286
+
Collection: tangled.RepoPullNSID,
1287
+
Repo: user.Did,
1288
+
Rkey: pull.Rkey,
1289
+
SwapRecord: ex.Cid,
1290
+
Record: &lexutil.LexiconTypeDecoder{
1291
+
Val: &tangled.RepoPull{
1292
+
Title: pull.Title,
1293
+
PullId: int64(pull.PullId),
1294
+
TargetRepo: string(f.RepoAt),
1295
+
TargetBranch: pull.TargetBranch,
1296
+
Patch: patch, // new patch
1297
+
Source: recordPullSource,
609
1298
},
610
-
})
611
-
if err != nil {
612
-
log.Println("failed to update record", err)
613
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
614
-
return
615
-
}
1299
+
},
1300
+
})
1301
+
if err != nil {
1302
+
log.Println("failed to update record", err)
1303
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1304
+
return
1305
+
}
1306
+
1307
+
if err = tx.Commit(); err != nil {
1308
+
log.Println("failed to commit transaction", err)
1309
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1310
+
return
1311
+
}
1312
+
1313
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1314
+
return
1315
+
}
1316
+
1317
+
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1318
+
user := s.auth.GetUser(r)
1319
+
1320
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1321
+
if !ok {
1322
+
log.Println("failed to get pull")
1323
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1324
+
return
1325
+
}
1326
+
1327
+
f, err := fullyResolvedRepo(r)
1328
+
if err != nil {
1329
+
log.Println("failed to get repo and knot", err)
1330
+
return
1331
+
}
1332
+
1333
+
if user.Did != pull.OwnerDid {
1334
+
log.Println("unauthorized user")
1335
+
w.WriteHeader(http.StatusUnauthorized)
1336
+
return
1337
+
}
1338
+
1339
+
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1340
+
if err != nil {
1341
+
log.Println("failed to get source repo", err)
1342
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1343
+
return
1344
+
}
1345
+
1346
+
// extract patch by performing compare
1347
+
ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1348
+
if err != nil {
1349
+
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1350
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1351
+
return
1352
+
}
1353
+
1354
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1355
+
if err != nil {
1356
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1357
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1358
+
return
1359
+
}
1360
+
1361
+
// update the hidden tracking branch to latest
1362
+
signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1363
+
if err != nil {
1364
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1365
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1366
+
return
1367
+
}
1368
+
1369
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1370
+
if err != nil || resp.StatusCode != http.StatusNoContent {
1371
+
log.Printf("failed to update tracking branch: %s", err)
1372
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1373
+
return
1374
+
}
1375
+
1376
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1377
+
comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1378
+
if err != nil {
1379
+
log.Printf("failed to compare branches: %s", err)
1380
+
s.pages.Notice(w, "resubmit-error", err.Error())
1381
+
return
1382
+
}
1383
+
1384
+
sourceRev := comparison.Rev2
1385
+
patch := comparison.Patch
1386
+
1387
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1388
+
s.pages.Notice(w, "resubmit-error", err.Error())
1389
+
return
1390
+
}
1391
+
1392
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1393
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1394
+
return
1395
+
}
1396
+
1397
+
tx, err := s.db.BeginTx(r.Context(), nil)
1398
+
if err != nil {
1399
+
log.Println("failed to start tx")
1400
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1401
+
return
1402
+
}
1403
+
defer tx.Rollback()
1404
+
1405
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1406
+
if err != nil {
1407
+
log.Println("failed to create pull request", err)
1408
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1409
+
return
1410
+
}
1411
+
client, _ := s.auth.AuthorizedClient(r)
1412
+
1413
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1414
+
if err != nil {
1415
+
// failed to get record
1416
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1417
+
return
1418
+
}
616
1419
617
-
if err = tx.Commit(); err != nil {
618
-
log.Println("failed to commit transaction", err)
619
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
620
-
return
621
-
}
1420
+
repoAt := pull.PullSource.RepoAt.String()
1421
+
recordPullSource := &tangled.RepoPull_Source{
1422
+
Branch: pull.PullSource.Branch,
1423
+
Repo: &repoAt,
1424
+
}
1425
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1426
+
Collection: tangled.RepoPullNSID,
1427
+
Repo: user.Did,
1428
+
Rkey: pull.Rkey,
1429
+
SwapRecord: ex.Cid,
1430
+
Record: &lexutil.LexiconTypeDecoder{
1431
+
Val: &tangled.RepoPull{
1432
+
Title: pull.Title,
1433
+
PullId: int64(pull.PullId),
1434
+
TargetRepo: string(f.RepoAt),
1435
+
TargetBranch: pull.TargetBranch,
1436
+
Patch: patch, // new patch
1437
+
Source: recordPullSource,
1438
+
},
1439
+
},
1440
+
})
1441
+
if err != nil {
1442
+
log.Println("failed to update record", err)
1443
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1444
+
return
1445
+
}
622
1446
623
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1447
+
if err = tx.Commit(); err != nil {
1448
+
log.Println("failed to commit transaction", err)
1449
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
624
1450
return
625
1451
}
1452
+
1453
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1454
+
return
1455
+
}
1456
+
1457
+
// validate a resubmission against a pull request
1458
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1459
+
if patch == "" {
1460
+
return fmt.Errorf("Patch is empty.")
1461
+
}
1462
+
1463
+
if patch == pull.LatestPatch() {
1464
+
return fmt.Errorf("Patch is identical to previous submission.")
1465
+
}
1466
+
1467
+
if !patchutil.IsPatchValid(patch) {
1468
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1469
+
}
1470
+
1471
+
return nil
626
1472
}
627
1473
628
1474
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
···
796
1642
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
797
1643
return
798
1644
}
799
-
800
-
// Very basic validation to check if it looks like a diff/patch
801
-
// A valid patch usually starts with diff or --- lines
802
-
func isPatchValid(patch string) bool {
803
-
// Basic validation to check if it looks like a diff/patch
804
-
// A valid patch usually starts with diff or --- lines
805
-
if len(patch) == 0 {
806
-
return false
807
-
}
808
-
809
-
lines := strings.Split(patch, "\n")
810
-
if len(lines) < 2 {
811
-
return false
812
-
}
813
-
814
-
// Check for common patch format markers
815
-
firstLine := strings.TrimSpace(lines[0])
816
-
return strings.HasPrefix(firstLine, "diff ") ||
817
-
strings.HasPrefix(firstLine, "--- ") ||
818
-
strings.HasPrefix(firstLine, "Index: ") ||
819
-
strings.HasPrefix(firstLine, "+++ ") ||
820
-
strings.HasPrefix(firstLine, "@@ ")
821
-
}
+483
-14
appview/state/repo.go
+483
-14
appview/state/repo.go
···
2
2
3
3
import (
4
4
"context"
5
+
"database/sql"
5
6
"encoding/json"
7
+
"errors"
6
8
"fmt"
7
9
"io"
8
10
"log"
9
-
"math/rand/v2"
11
+
mathrand "math/rand/v2"
10
12
"net/http"
11
13
"path"
12
14
"slices"
···
19
21
"github.com/bluesky-social/indigo/atproto/syntax"
20
22
securejoin "github.com/cyphar/filepath-securejoin"
21
23
"github.com/go-chi/chi/v5"
24
+
"github.com/go-git/go-git/v5/plumbing"
22
25
"tangled.sh/tangled.sh/core/api/tangled"
26
+
"tangled.sh/tangled.sh/core/appview"
23
27
"tangled.sh/tangled.sh/core/appview/auth"
24
28
"tangled.sh/tangled.sh/core/appview/db"
25
29
"tangled.sh/tangled.sh/core/appview/pages"
30
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
31
+
"tangled.sh/tangled.sh/core/appview/pagination"
26
32
"tangled.sh/tangled.sh/core/types"
27
33
28
34
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
245
251
if !s.config.Dev {
246
252
protocol = "https"
247
253
}
254
+
255
+
if !plumbing.IsHash(ref) {
256
+
s.pages.Error404(w)
257
+
return
258
+
}
259
+
248
260
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
249
261
if err != nil {
250
262
log.Println("failed to reach knotserver", err)
···
309
321
user := s.auth.GetUser(r)
310
322
311
323
var breadcrumbs [][]string
312
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
324
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
313
325
if treePath != "" {
314
326
for idx, elem := range strings.Split(treePath, "/") {
315
327
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
316
328
}
317
329
}
318
330
319
-
baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
320
-
baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
331
+
baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
332
+
baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
321
333
322
334
s.pages.RepoTree(w, pages.RepoTreeParams{
323
335
LoggedInUser: user,
···
444
456
}
445
457
446
458
var breadcrumbs [][]string
447
-
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
459
+
breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
448
460
if filePath != "" {
449
461
for idx, elem := range strings.Split(filePath, "/") {
450
462
breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
451
463
}
452
464
}
453
465
466
+
showRendered := false
467
+
renderToggle := false
468
+
469
+
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
470
+
renderToggle = true
471
+
showRendered = r.URL.Query().Get("code") != "true"
472
+
}
473
+
454
474
user := s.auth.GetUser(r)
455
475
s.pages.RepoBlob(w, pages.RepoBlobParams{
456
476
LoggedInUser: user,
457
477
RepoInfo: f.RepoInfo(s, user),
458
478
RepoBlobResponse: result,
459
479
BreadCrumbs: breadcrumbs,
480
+
ShowRendered: showRendered,
481
+
RenderToggle: renderToggle,
460
482
})
461
483
return
462
484
}
···
564
586
}
565
587
}()
566
588
567
-
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
589
+
err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
568
590
if err != nil {
569
591
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
570
592
return
···
594
616
595
617
}
596
618
619
+
func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
620
+
user := s.auth.GetUser(r)
621
+
622
+
f, err := fullyResolvedRepo(r)
623
+
if err != nil {
624
+
log.Println("failed to get repo and knot", err)
625
+
return
626
+
}
627
+
628
+
// remove record from pds
629
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
630
+
repoRkey := f.RepoAt.RecordKey().String()
631
+
_, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{
632
+
Collection: tangled.RepoNSID,
633
+
Repo: user.Did,
634
+
Rkey: repoRkey,
635
+
})
636
+
if err != nil {
637
+
log.Printf("failed to delete record: %s", err)
638
+
s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
639
+
return
640
+
}
641
+
log.Println("removed repo record ", f.RepoAt.String())
642
+
643
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
644
+
if err != nil {
645
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
646
+
return
647
+
}
648
+
649
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
650
+
if err != nil {
651
+
log.Println("failed to create client to ", f.Knot)
652
+
return
653
+
}
654
+
655
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
656
+
if err != nil {
657
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
658
+
return
659
+
}
660
+
661
+
if ksResp.StatusCode != http.StatusNoContent {
662
+
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
663
+
} else {
664
+
log.Println("removed repo from knot ", f.Knot)
665
+
}
666
+
667
+
tx, err := s.db.BeginTx(r.Context(), nil)
668
+
if err != nil {
669
+
log.Println("failed to start tx")
670
+
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
671
+
return
672
+
}
673
+
defer func() {
674
+
tx.Rollback()
675
+
err = s.enforcer.E.LoadPolicy()
676
+
if err != nil {
677
+
log.Println("failed to rollback policies")
678
+
}
679
+
}()
680
+
681
+
// remove collaborator RBAC
682
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
683
+
if err != nil {
684
+
s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
685
+
return
686
+
}
687
+
for _, c := range repoCollaborators {
688
+
did := c[0]
689
+
s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
690
+
}
691
+
log.Println("removed collaborators")
692
+
693
+
// remove repo RBAC
694
+
err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
695
+
if err != nil {
696
+
s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
697
+
return
698
+
}
699
+
700
+
// remove repo from db
701
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
702
+
if err != nil {
703
+
s.pages.Notice(w, "settings-delete", "Failed to update appview")
704
+
return
705
+
}
706
+
log.Println("removed repo from db")
707
+
708
+
err = tx.Commit()
709
+
if err != nil {
710
+
log.Println("failed to commit changes", err)
711
+
http.Error(w, err.Error(), http.StatusInternalServerError)
712
+
return
713
+
}
714
+
715
+
err = s.enforcer.E.SavePolicy()
716
+
if err != nil {
717
+
log.Println("failed to update ACLs", err)
718
+
http.Error(w, err.Error(), http.StatusInternalServerError)
719
+
return
720
+
}
721
+
722
+
s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
723
+
}
724
+
725
+
func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
726
+
f, err := fullyResolvedRepo(r)
727
+
if err != nil {
728
+
log.Println("failed to get repo and knot", err)
729
+
return
730
+
}
731
+
732
+
branch := r.FormValue("branch")
733
+
if branch == "" {
734
+
http.Error(w, "malformed form", http.StatusBadRequest)
735
+
return
736
+
}
737
+
738
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
739
+
if err != nil {
740
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
741
+
return
742
+
}
743
+
744
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
745
+
if err != nil {
746
+
log.Println("failed to create client to ", f.Knot)
747
+
return
748
+
}
749
+
750
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
751
+
if err != nil {
752
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
753
+
return
754
+
}
755
+
756
+
if ksResp.StatusCode != http.StatusNoContent {
757
+
s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
758
+
return
759
+
}
760
+
761
+
w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
762
+
}
763
+
597
764
func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
598
765
f, err := fullyResolvedRepo(r)
599
766
if err != nil {
···
612
779
613
780
isCollaboratorInviteAllowed := false
614
781
if user != nil {
615
-
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
782
+
ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
616
783
if err == nil && ok {
617
784
isCollaboratorInviteAllowed = true
618
785
}
619
786
}
620
787
788
+
var branchNames []string
789
+
var defaultBranch string
790
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
791
+
if err != nil {
792
+
log.Println("failed to create unsigned client", err)
793
+
} else {
794
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
795
+
if err != nil {
796
+
log.Println("failed to reach knotserver", err)
797
+
} else {
798
+
defer resp.Body.Close()
799
+
800
+
body, err := io.ReadAll(resp.Body)
801
+
if err != nil {
802
+
log.Printf("Error reading response body: %v", err)
803
+
} else {
804
+
var result types.RepoBranchesResponse
805
+
err = json.Unmarshal(body, &result)
806
+
if err != nil {
807
+
log.Println("failed to parse response:", err)
808
+
} else {
809
+
for _, branch := range result.Branches {
810
+
branchNames = append(branchNames, branch.Name)
811
+
}
812
+
}
813
+
}
814
+
}
815
+
816
+
resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName)
817
+
if err != nil {
818
+
log.Println("failed to reach knotserver", err)
819
+
} else {
820
+
defer resp.Body.Close()
821
+
822
+
body, err := io.ReadAll(resp.Body)
823
+
if err != nil {
824
+
log.Printf("Error reading response body: %v", err)
825
+
} else {
826
+
var result types.RepoDefaultBranchResponse
827
+
err = json.Unmarshal(body, &result)
828
+
if err != nil {
829
+
log.Println("failed to parse response:", err)
830
+
} else {
831
+
defaultBranch = result.Branch
832
+
}
833
+
}
834
+
}
835
+
}
836
+
621
837
s.pages.RepoSettings(w, pages.RepoSettingsParams{
622
838
LoggedInUser: user,
623
839
RepoInfo: f.RepoInfo(s, user),
624
840
Collaborators: repoCollaborators,
625
841
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
842
+
Branches: branchNames,
843
+
DefaultBranch: defaultBranch,
626
844
})
627
845
}
628
846
}
···
645
863
}
646
864
647
865
func (f *FullyResolvedRepo) OwnerSlashRepo() string {
866
+
handle := f.OwnerId.Handle
867
+
868
+
var p string
869
+
if handle != "" && !handle.IsInvalidHandle() {
870
+
p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
871
+
} else {
872
+
p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
873
+
}
874
+
875
+
return p
876
+
}
877
+
878
+
func (f *FullyResolvedRepo) DidSlashRepo() string {
648
879
p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
649
880
return p
650
881
}
651
882
652
883
func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
653
-
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
884
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
654
885
if err != nil {
655
886
return nil, err
656
887
}
···
711
942
if err != nil {
712
943
log.Println("failed to get issue count for ", f.RepoAt)
713
944
}
945
+
source, err := db.GetRepoSource(s.db, f.RepoAt)
946
+
if errors.Is(err, sql.ErrNoRows) {
947
+
source = ""
948
+
} else if err != nil {
949
+
log.Println("failed to get repo source for ", f.RepoAt, err)
950
+
}
951
+
952
+
var sourceRepo *db.Repo
953
+
if source != "" {
954
+
sourceRepo, err = db.GetRepoByAtUri(s.db, source)
955
+
if err != nil {
956
+
log.Println("failed to get repo by at uri", err)
957
+
}
958
+
}
959
+
960
+
var sourceHandle *identity.Identity
961
+
if sourceRepo != nil {
962
+
sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
963
+
if err != nil {
964
+
log.Println("failed to resolve source repo", err)
965
+
}
966
+
}
714
967
715
968
knot := f.Knot
969
+
var disableFork bool
970
+
us, err := NewUnsignedClient(knot, s.config.Dev)
971
+
if err != nil {
972
+
log.Printf("failed to create unsigned client for %s: %v", knot, err)
973
+
} else {
974
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
975
+
if err != nil {
976
+
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
977
+
} else {
978
+
defer resp.Body.Close()
979
+
body, err := io.ReadAll(resp.Body)
980
+
if err != nil {
981
+
log.Printf("error reading branch response body: %v", err)
982
+
} else {
983
+
var branchesResp types.RepoBranchesResponse
984
+
if err := json.Unmarshal(body, &branchesResp); err != nil {
985
+
log.Printf("error parsing branch response: %v", err)
986
+
} else {
987
+
disableFork = false
988
+
}
989
+
990
+
if len(branchesResp.Branches) == 0 {
991
+
disableFork = true
992
+
}
993
+
}
994
+
}
995
+
}
996
+
716
997
if knot == "knot1.tangled.sh" {
717
998
knot = "tangled.sh"
718
999
}
719
1000
720
-
return pages.RepoInfo{
1001
+
repoInfo := pages.RepoInfo{
721
1002
OwnerDid: f.OwnerDid(),
722
1003
OwnerHandle: f.OwnerHandle(),
723
1004
Name: f.RepoName,
···
731
1012
IssueCount: issueCount,
732
1013
PullCount: pullCount,
733
1014
},
1015
+
DisableFork: disableFork,
734
1016
}
1017
+
1018
+
if sourceRepo != nil {
1019
+
repoInfo.Source = sourceRepo
1020
+
repoInfo.SourceHandle = sourceHandle.Handle.String()
1021
+
}
1022
+
1023
+
return repoInfo
735
1024
}
736
1025
737
1026
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
···
829
1118
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
830
1119
Collection: tangled.RepoIssueStateNSID,
831
1120
Repo: user.Did,
832
-
Rkey: s.TID(),
1121
+
Rkey: appview.TID(),
833
1122
Record: &lexutil.LexiconTypeDecoder{
834
1123
Val: &tangled.RepoIssueState{
835
1124
Issue: issue.IssueAt,
···
932
1221
return
933
1222
}
934
1223
935
-
commentId := rand.IntN(1000000)
936
-
rkey := s.TID()
1224
+
commentId := mathrand.IntN(1000000)
1225
+
rkey := appview.TID()
937
1226
938
1227
err := db.NewIssueComment(s.db, &db.Comment{
939
1228
OwnerDid: user.Did,
···
1271
1560
isOpen = true
1272
1561
}
1273
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
+
1274
1569
user := s.auth.GetUser(r)
1275
1570
f, err := fullyResolvedRepo(r)
1276
1571
if err != nil {
···
1278
1573
return
1279
1574
}
1280
1575
1281
-
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1576
+
issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1282
1577
if err != nil {
1283
1578
log.Println("failed to get issues", err)
1284
1579
s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
···
1305
1600
Issues: issues,
1306
1601
DidHandleMap: didHandleMap,
1307
1602
FilteringByOpen: isOpen,
1603
+
Page: page,
1308
1604
})
1309
1605
return
1310
1606
}
···
1363
1659
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1364
1660
Collection: tangled.RepoIssueNSID,
1365
1661
Repo: user.Did,
1366
-
Rkey: s.TID(),
1662
+
Rkey: appview.TID(),
1367
1663
Record: &lexutil.LexiconTypeDecoder{
1368
1664
Val: &tangled.RepoIssue{
1369
1665
Repo: atUri,
···
1391
1687
return
1392
1688
}
1393
1689
}
1690
+
1691
+
func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1692
+
user := s.auth.GetUser(r)
1693
+
f, err := fullyResolvedRepo(r)
1694
+
if err != nil {
1695
+
log.Printf("failed to resolve source repo: %v", err)
1696
+
return
1697
+
}
1698
+
1699
+
switch r.Method {
1700
+
case http.MethodGet:
1701
+
user := s.auth.GetUser(r)
1702
+
knots, err := s.enforcer.GetDomainsForUser(user.Did)
1703
+
if err != nil {
1704
+
s.pages.Notice(w, "repo", "Invalid user account.")
1705
+
return
1706
+
}
1707
+
1708
+
s.pages.ForkRepo(w, pages.ForkRepoParams{
1709
+
LoggedInUser: user,
1710
+
Knots: knots,
1711
+
RepoInfo: f.RepoInfo(s, user),
1712
+
})
1713
+
1714
+
case http.MethodPost:
1715
+
1716
+
knot := r.FormValue("knot")
1717
+
if knot == "" {
1718
+
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1719
+
return
1720
+
}
1721
+
1722
+
ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1723
+
if err != nil || !ok {
1724
+
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1725
+
return
1726
+
}
1727
+
1728
+
forkName := fmt.Sprintf("%s", f.RepoName)
1729
+
1730
+
// this check is *only* to see if the forked repo name already exists
1731
+
// in the user's account.
1732
+
existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1733
+
if err != nil {
1734
+
if errors.Is(err, sql.ErrNoRows) {
1735
+
// no existing repo with this name found, we can use the name as is
1736
+
} else {
1737
+
log.Println("error fetching existing repo from db", err)
1738
+
s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1739
+
return
1740
+
}
1741
+
} else if existingRepo != nil {
1742
+
// repo with this name already exists, append random string
1743
+
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1744
+
}
1745
+
secret, err := db.GetRegistrationKey(s.db, knot)
1746
+
if err != nil {
1747
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1748
+
return
1749
+
}
1750
+
1751
+
client, err := NewSignedClient(knot, secret, s.config.Dev)
1752
+
if err != nil {
1753
+
s.pages.Notice(w, "repo", "Failed to reach knot server.")
1754
+
return
1755
+
}
1756
+
1757
+
var uri string
1758
+
if s.config.Dev {
1759
+
uri = "http"
1760
+
} else {
1761
+
uri = "https"
1762
+
}
1763
+
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1764
+
sourceAt := f.RepoAt.String()
1765
+
1766
+
rkey := appview.TID()
1767
+
repo := &db.Repo{
1768
+
Did: user.Did,
1769
+
Name: forkName,
1770
+
Knot: knot,
1771
+
Rkey: rkey,
1772
+
Source: sourceAt,
1773
+
}
1774
+
1775
+
tx, err := s.db.BeginTx(r.Context(), nil)
1776
+
if err != nil {
1777
+
log.Println(err)
1778
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1779
+
return
1780
+
}
1781
+
defer func() {
1782
+
tx.Rollback()
1783
+
err = s.enforcer.E.LoadPolicy()
1784
+
if err != nil {
1785
+
log.Println("failed to rollback policies")
1786
+
}
1787
+
}()
1788
+
1789
+
resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
1790
+
if err != nil {
1791
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1792
+
return
1793
+
}
1794
+
1795
+
switch resp.StatusCode {
1796
+
case http.StatusConflict:
1797
+
s.pages.Notice(w, "repo", "A repository with that name already exists.")
1798
+
return
1799
+
case http.StatusInternalServerError:
1800
+
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1801
+
case http.StatusNoContent:
1802
+
// continue
1803
+
}
1804
+
1805
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
1806
+
1807
+
addedAt := time.Now().Format(time.RFC3339)
1808
+
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
1809
+
Collection: tangled.RepoNSID,
1810
+
Repo: user.Did,
1811
+
Rkey: rkey,
1812
+
Record: &lexutil.LexiconTypeDecoder{
1813
+
Val: &tangled.Repo{
1814
+
Knot: repo.Knot,
1815
+
Name: repo.Name,
1816
+
AddedAt: &addedAt,
1817
+
Owner: user.Did,
1818
+
Source: &sourceAt,
1819
+
}},
1820
+
})
1821
+
if err != nil {
1822
+
log.Printf("failed to create record: %s", err)
1823
+
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1824
+
return
1825
+
}
1826
+
log.Println("created repo record: ", atresp.Uri)
1827
+
1828
+
repo.AtUri = atresp.Uri
1829
+
err = db.AddRepo(tx, repo)
1830
+
if err != nil {
1831
+
log.Println(err)
1832
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1833
+
return
1834
+
}
1835
+
1836
+
// acls
1837
+
p, _ := securejoin.SecureJoin(user.Did, forkName)
1838
+
err = s.enforcer.AddRepo(user.Did, knot, p)
1839
+
if err != nil {
1840
+
log.Println(err)
1841
+
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1842
+
return
1843
+
}
1844
+
1845
+
err = tx.Commit()
1846
+
if err != nil {
1847
+
log.Println("failed to commit changes", err)
1848
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1849
+
return
1850
+
}
1851
+
1852
+
err = s.enforcer.E.SavePolicy()
1853
+
if err != nil {
1854
+
log.Println("failed to update ACLs", err)
1855
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1856
+
return
1857
+
}
1858
+
1859
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1860
+
return
1861
+
}
1862
+
}
+15
-1
appview/state/repo_util.go
+15
-1
appview/state/repo_util.go
···
2
2
3
3
import (
4
4
"context"
5
+
"crypto/rand"
5
6
"fmt"
6
7
"log"
8
+
"math/big"
7
9
"net/http"
8
10
9
11
"github.com/bluesky-social/indigo/atproto/identity"
···
56
58
57
59
func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
58
60
if u != nil {
59
-
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
61
+
r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo())
60
62
return pages.RolesInRepo{r}
61
63
} else {
62
64
return pages.RolesInRepo{}
···
112
114
113
115
return emailToDidOrHandle
114
116
}
117
+
118
+
func randomString(n int) string {
119
+
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
120
+
result := make([]byte, n)
121
+
122
+
for i := 0; i < n; i++ {
123
+
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
124
+
result[i] = letters[n.Int64()]
125
+
}
126
+
127
+
return string(result)
128
+
}
+39
-22
appview/state/router.go
+39
-22
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)
···
85
87
})
86
88
})
87
89
90
+
r.Route("/fork", func(r chi.Router) {
91
+
r.Use(middleware.AuthMiddleware(s.auth))
92
+
r.Get("/", s.ForkRepo)
93
+
r.Post("/", s.ForkRepo)
94
+
})
95
+
88
96
r.Route("/pulls", func(r chi.Router) {
89
97
r.Get("/", s.RepoPulls)
90
-
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
98
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) {
91
99
r.Get("/", s.NewPull)
100
+
r.Get("/patch-upload", s.PatchUploadFragment)
101
+
r.Post("/validate-patch", s.ValidatePatch)
102
+
r.Get("/compare-branches", s.CompareBranchesFragment)
103
+
r.Get("/compare-forks", s.CompareForksFragment)
104
+
r.Get("/fork-branches", s.CompareForksBranchesFragment)
92
105
r.Post("/", s.NewPull)
93
106
})
94
107
···
98
111
99
112
r.Route("/round/{round}", func(r chi.Router) {
100
113
r.Get("/", s.RepoPullPatch)
114
+
r.Get("/interdiff", s.RepoPullInterdiff)
101
115
r.Get("/actions", s.PullActions)
102
-
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
116
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) {
103
117
r.Get("/", s.PullComment)
104
118
r.Post("/", s.PullComment)
105
119
})
···
110
124
})
111
125
112
126
r.Group(func(r chi.Router) {
113
-
r.Use(AuthMiddleware(s))
127
+
r.Use(middleware.AuthMiddleware(s.auth))
114
128
r.Route("/resubmit", func(r chi.Router) {
115
129
r.Get("/", s.ResubmitPull)
116
130
r.Post("/", s.ResubmitPull)
···
133
147
134
148
// settings routes, needs auth
135
149
r.Group(func(r chi.Router) {
136
-
r.Use(AuthMiddleware(s))
150
+
r.Use(middleware.AuthMiddleware(s.auth))
137
151
// repo description can only be edited by owner
138
152
r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) {
139
153
r.Put("/", s.RepoDescription)
···
143
157
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
144
158
r.Get("/", s.RepoSettings)
145
159
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
160
+
r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo)
161
+
r.Put("/branches/default", s.SetDefaultBranch)
146
162
})
147
163
})
148
164
})
···
162
178
163
179
r.Get("/", s.Timeline)
164
180
165
-
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
181
+
r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout)
166
182
167
183
r.Route("/login", func(r chi.Router) {
168
184
r.Get("/", s.Login)
···
170
186
})
171
187
172
188
r.Route("/knots", func(r chi.Router) {
173
-
r.Use(AuthMiddleware(s))
189
+
r.Use(middleware.AuthMiddleware(s.auth))
174
190
r.Get("/", s.Knots)
175
191
r.Post("/key", s.RegistrationKey)
176
192
···
188
204
189
205
r.Route("/repo", func(r chi.Router) {
190
206
r.Route("/new", func(r chi.Router) {
191
-
r.Use(AuthMiddleware(s))
207
+
r.Use(middleware.AuthMiddleware(s.auth))
192
208
r.Get("/", s.NewRepo)
193
209
r.Post("/", s.NewRepo)
194
210
})
195
211
// r.Post("/import", s.ImportRepo)
196
212
})
197
213
198
-
r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) {
214
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) {
199
215
r.Post("/", s.Follow)
200
216
r.Delete("/", s.Follow)
201
217
})
202
218
203
-
r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) {
219
+
r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) {
204
220
r.Post("/", s.Star)
205
221
r.Delete("/", s.Star)
206
222
})
207
223
208
-
r.Route("/settings", func(r chi.Router) {
209
-
r.Use(AuthMiddleware(s))
210
-
r.Get("/", s.Settings)
211
-
r.Put("/keys", s.SettingsKeys)
212
-
r.Delete("/keys", s.SettingsKeys)
213
-
r.Put("/emails", s.SettingsEmails)
214
-
r.Delete("/emails", s.SettingsEmails)
215
-
r.Get("/emails/verify", s.SettingsEmailsVerify)
216
-
r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend)
217
-
r.Post("/emails/primary", s.SettingsEmailsPrimary)
218
-
})
224
+
r.Mount("/settings", s.SettingsRouter())
219
225
220
226
r.Get("/keys/{user}", s.Keys)
221
227
···
224
230
})
225
231
return r
226
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
-
}
+150
appview/state/signer.go
+150
appview/state/signer.go
···
7
7
"encoding/hex"
8
8
"encoding/json"
9
9
"fmt"
10
+
"io"
11
+
"log"
10
12
"net/http"
11
13
"net/url"
12
14
"time"
···
103
105
return s.client.Do(req)
104
106
}
105
107
108
+
func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
109
+
const (
110
+
Method = "POST"
111
+
Endpoint = "/repo/fork"
112
+
)
113
+
114
+
body, _ := json.Marshal(map[string]any{
115
+
"did": ownerDid,
116
+
"source": source,
117
+
"name": name,
118
+
})
119
+
120
+
req, err := s.newRequest(Method, Endpoint, body)
121
+
if err != nil {
122
+
return nil, err
123
+
}
124
+
125
+
return s.client.Do(req)
126
+
}
127
+
106
128
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
107
129
const (
108
130
Method = "DELETE"
···
140
162
return s.client.Do(req)
141
163
}
142
164
165
+
func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) {
166
+
const (
167
+
Method = "PUT"
168
+
)
169
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
170
+
171
+
body, _ := json.Marshal(map[string]any{
172
+
"branch": branch,
173
+
})
174
+
175
+
req, err := s.newRequest(Method, endpoint, body)
176
+
if err != nil {
177
+
return nil, err
178
+
}
179
+
180
+
return s.client.Do(req)
181
+
}
182
+
143
183
func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) {
144
184
const (
145
185
Method = "POST"
···
205
245
return s.client.Do(req)
206
246
}
207
247
248
+
func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) {
249
+
const (
250
+
Method = "POST"
251
+
)
252
+
endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, forkBranch, remoteBranch)
253
+
254
+
req, err := s.newRequest(Method, endpoint, nil)
255
+
if err != nil {
256
+
return nil, err
257
+
}
258
+
259
+
return s.client.Do(req)
260
+
}
261
+
208
262
type UnsignedClient struct {
209
263
Url *url.URL
210
264
client *http.Client
···
268
322
269
323
return us.client.Do(req)
270
324
}
325
+
326
+
func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) {
327
+
const (
328
+
Method = "GET"
329
+
)
330
+
331
+
endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, branch)
332
+
333
+
req, err := us.newRequest(Method, endpoint, nil)
334
+
if err != nil {
335
+
return nil, err
336
+
}
337
+
338
+
return us.client.Do(req)
339
+
}
340
+
341
+
func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*http.Response, error) {
342
+
const (
343
+
Method = "GET"
344
+
)
345
+
346
+
endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName)
347
+
348
+
req, err := us.newRequest(Method, endpoint, nil)
349
+
if err != nil {
350
+
return nil, err
351
+
}
352
+
353
+
return us.client.Do(req)
354
+
}
355
+
356
+
func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) {
357
+
const (
358
+
Method = "GET"
359
+
Endpoint = "/capabilities"
360
+
)
361
+
362
+
req, err := us.newRequest(Method, Endpoint, nil)
363
+
if err != nil {
364
+
return nil, err
365
+
}
366
+
367
+
resp, err := us.client.Do(req)
368
+
if err != nil {
369
+
return nil, err
370
+
}
371
+
defer resp.Body.Close()
372
+
373
+
var capabilities types.Capabilities
374
+
if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil {
375
+
return nil, err
376
+
}
377
+
378
+
return &capabilities, nil
379
+
}
380
+
381
+
func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) {
382
+
const (
383
+
Method = "GET"
384
+
)
385
+
386
+
endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2))
387
+
388
+
req, err := us.newRequest(Method, endpoint, nil)
389
+
if err != nil {
390
+
return nil, fmt.Errorf("Failed to create request.")
391
+
}
392
+
393
+
compareResp, err := us.client.Do(req)
394
+
if err != nil {
395
+
return nil, fmt.Errorf("Failed to create request.")
396
+
}
397
+
defer compareResp.Body.Close()
398
+
399
+
switch compareResp.StatusCode {
400
+
case 404:
401
+
case 400:
402
+
return nil, fmt.Errorf("Branch comparisons not supported on this knot.")
403
+
}
404
+
405
+
respBody, err := io.ReadAll(compareResp.Body)
406
+
if err != nil {
407
+
log.Println("failed to compare across branches")
408
+
return nil, fmt.Errorf("Failed to compare branches.")
409
+
}
410
+
defer compareResp.Body.Close()
411
+
412
+
var formatPatchResponse types.RepoFormatPatchResponse
413
+
err = json.Unmarshal(respBody, &formatPatchResponse)
414
+
if err != nil {
415
+
log.Println("failed to unmarshal format-patch response", err)
416
+
return nil, fmt.Errorf("failed to compare branches.")
417
+
}
418
+
419
+
return &formatPatchResponse, nil
420
+
}
+4
-3
appview/state/star.go
+4
-3
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,
···
62
63
63
64
log.Println("created atproto record: ", resp.Uri)
64
65
65
-
s.pages.StarFragment(w, pages.StarFragmentParams{
66
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
66
67
IsStarred: true,
67
68
RepoAt: subjectUri,
68
69
Stats: db.RepoStats{
···
101
102
log.Println("failed to get star count for ", subjectUri)
102
103
}
103
104
104
-
s.pages.StarFragment(w, pages.StarFragmentParams{
105
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
105
106
IsStarred: false,
106
107
RepoAt: subjectUri,
107
108
Stats: db.RepoStats{
+8
-5
appview/state/state.go
+8
-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) {
···
190
190
for _, ev := range timeline {
191
191
if ev.Repo != nil {
192
192
didsToResolve = append(didsToResolve, ev.Repo.Did)
193
+
if ev.Source != nil {
194
+
didsToResolve = append(didsToResolve, ev.Source.Did)
195
+
}
193
196
}
194
197
if ev.Follow != nil {
195
198
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
···
519
522
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
520
523
Collection: tangled.KnotMemberNSID,
521
524
Repo: currentUser.Did,
522
-
Rkey: s.TID(),
525
+
Rkey: appview.TID(),
523
526
Record: &lexutil.LexiconTypeDecoder{
524
527
Val: &tangled.KnotMember{
525
528
Member: memberIdent.DID.String(),
···
643
646
return
644
647
}
645
648
646
-
rkey := s.TID()
649
+
rkey := appview.TID()
647
650
repo := &db.Repo{
648
651
Did: user.Did,
649
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
+
}
+1
cmd/gen.go
+1
cmd/gen.go
+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
+
}
+52
docker/Dockerfile
+52
docker/Dockerfile
···
1
+
FROM docker.io/golang:1.24-alpine3.21 AS build
2
+
3
+
ENV CGO_ENABLED=1
4
+
5
+
RUN apk add --no-cache gcc musl-dev
6
+
7
+
WORKDIR /usr/src/app
8
+
9
+
COPY go.mod go.sum ./
10
+
RUN go mod download
11
+
12
+
COPY . .
13
+
RUN go build -v \
14
+
-o /usr/local/bin/knotserver \
15
+
-ldflags='-s -w -extldflags "-static"' \
16
+
./cmd/knotserver && \
17
+
go build -v \
18
+
-o /usr/local/bin/keyfetch \
19
+
./cmd/keyfetch && \
20
+
go build -v \
21
+
-o /usr/local/bin/repoguard \
22
+
./cmd/repoguard
23
+
24
+
FROM docker.io/alpine:3.21
25
+
26
+
LABEL org.opencontainers.image.title=Tangled
27
+
LABEL org.opencontainers.image.description="Tangled is a decentralized and open code collaboration platform, built on atproto."
28
+
LABEL org.opencontainers.image.vendor=Tangled.sh
29
+
LABEL org.opencontainers.image.licenses=MIT
30
+
LABEL org.opencontainers.image.url=https://tangled.sh
31
+
LABEL org.opencontainers.image.source=https://tangled.sh/@tangled.sh/core
32
+
33
+
RUN apk add --no-cache shadow s6-overlay execline openssh git && \
34
+
adduser --disabled-password git && \
35
+
# We need to set password anyway since otherwise ssh won't work
36
+
head -c 32 /dev/random | base64 | tr -dc 'a-zA-Z0-9' | passwd git --stdin && \
37
+
mkdir /app && mkdir /home/git/repositories
38
+
39
+
COPY --from=build /usr/local/bin/knotserver /usr/local/bin
40
+
COPY --from=build /usr/local/bin/keyfetch /usr/local/libexec/tangled-keyfetch
41
+
COPY --from=build /usr/local/bin/repoguard /home/git/repoguard
42
+
COPY docker/rootfs/ .
43
+
44
+
RUN chown root:root /usr/local/libexec/tangled-keyfetch && \
45
+
chmod 755 /usr/local/libexec/tangled-keyfetch && \
46
+
chown git:git /home/git/repoguard && \
47
+
chown git:git /app && chown git:git /home/git/repositories
48
+
49
+
EXPOSE 22
50
+
EXPOSE 5555
51
+
52
+
ENTRYPOINT ["/init"]
+17
docker/docker-compose.yml
+17
docker/docker-compose.yml
···
1
+
services:
2
+
knot:
3
+
build:
4
+
context: ..
5
+
dockerfile: docker/Dockerfile
6
+
environment:
7
+
KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME}
8
+
KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET}
9
+
KNOT_SERVER_DB_PATH: "/app/knotserver.db"
10
+
KNOT_REPO_SCAN_PATH: "/home/git/repositories"
11
+
volumes:
12
+
- "./keys:/etc/ssh/keys"
13
+
- "./repositories:/home/git/repositories"
14
+
- "./server:/app"
15
+
ports:
16
+
- "5555:5555"
17
+
- "2222:22"
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
1
+
oneshot
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
1
+
/etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base
This is a binary file and will not be displayed.
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
1
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base
This is a binary file and will not be displayed.
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys
This is a binary file and will not be displayed.
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
1
+
longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver
This is a binary file and will not be displayed.
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd
This is a binary file and will not be displayed.
+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
1
+
#!/usr/bin/execlineb -P
2
+
3
+
foreground {
4
+
if -n { test -d /etc/ssh/keys }
5
+
mkdir /etc/ssh/keys
6
+
}
7
+
8
+
foreground {
9
+
if -n { test -f /etc/ssh/keys/ssh_host_rsa_key }
10
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N ""
11
+
}
12
+
13
+
foreground {
14
+
if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key }
15
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N ""
16
+
}
17
+
18
+
foreground {
19
+
if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key }
20
+
ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N ""
21
+
}
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
···
1
+
HostKey /etc/ssh/keys/ssh_host_rsa_key
2
+
HostKey /etc/ssh/keys/ssh_host_ecdsa_key
3
+
HostKey /etc/ssh/keys/ssh_host_ed25519_key
4
+
5
+
PasswordAuthentication no
6
+
7
+
Match User git
8
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories
9
+
AuthorizedKeysCommandUser nobody
+76
docs/contributing.md
+76
docs/contributing.md
···
1
+
# tangled contributing guide
2
+
3
+
## commit guidelines
4
+
5
+
We follow a commit style similar to the Go project. Please keep commits:
6
+
7
+
* **atomic**: each commit should represent one logical change
8
+
* **descriptive**: the commit message should clearly describe what the
9
+
change does and why it's needed
10
+
11
+
### message format
12
+
13
+
```
14
+
<service/top-level directory>: <affected package/directory>: <short summary of change>
15
+
16
+
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
+
```
22
+
23
+
Here are some examples:
24
+
25
+
```
26
+
appview: state: fix token expiry check in middleware
27
+
28
+
The previous check did not account for clock drift, leading to premature
29
+
token invalidation.
30
+
```
31
+
32
+
```
33
+
knotserver: git/service: improve error checking in upload-pack
34
+
```
35
+
36
+
### general notes
37
+
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.
42
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
43
+
"fixed bug" or "fixes bug").
44
+
- Try to keep the summary line under 72 characters, but we aren't too
45
+
fussed about this.
46
+
- Don't include unrelated changes in the same commit.
47
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
48
+
before submitting if necessary.
49
+
50
+
## proposals for bigger changes
51
+
52
+
Small fixes like typos, minor bugs, or trivial refactors can be
53
+
submitted directly as PRs.
54
+
55
+
For larger changesโespecially those introducing new features,
56
+
significant refactoring, or altering system behaviorโplease open a
57
+
proposal first. This helps us evaluate the scope, design, and potential
58
+
impact before implementation.
59
+
60
+
### proposal format
61
+
62
+
Create a new issue titled:
63
+
64
+
```
65
+
proposal: <affected scope>: <summary of change>
66
+
```
67
+
68
+
In the description, explain:
69
+
70
+
- What the change is
71
+
- Why it's needed
72
+
- How you plan to implement it (roughly)
73
+
- Any open questions or tradeoffs
74
+
75
+
We'll use the issue thread to discuss and refine the idea before moving
76
+
forward.
+108
docs/knot-hosting.md
+108
docs/knot-hosting.md
···
1
+
# knot self-hosting guide
2
+
3
+
So you want to run your own knot server? Great! Here are a few prerequisites:
4
+
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
6
+
2. A (sub)domain name. People generally use `knot.example.com`.
7
+
3. A valid SSL certificate for your domain.
8
+
9
+
There's a couple of ways to get started:
10
+
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
11
+
* Docker: Documented below.
12
+
* Manual: Documented below.
13
+
14
+
## docker setup
15
+
16
+
Clone this repository:
17
+
18
+
```
19
+
git clone https://tangled.sh/@tangled.sh/core
20
+
```
21
+
22
+
Modify the `docker/docker-compose.yml`, specifically the
23
+
`KNOT_SERVER_SECRET` and `KNOT_SERVER_HOSTNAME` env vars. Then run:
24
+
25
+
```
26
+
docker compose -f docker/docker-compose.yml up
27
+
```
28
+
29
+
## manual setup
30
+
31
+
First, clone this repository:
32
+
33
+
```
34
+
git clone https://tangled.sh/@tangled.sh/core
35
+
```
36
+
37
+
Then, build our binaries (you need to have Go installed):
38
+
* `knotserver`: the main server program
39
+
* `keyfetch`: utility to fetch ssh pubkeys
40
+
* `repoguard`: enforces repository access control
41
+
42
+
```
43
+
cd core
44
+
export CGO_ENABLED=1
45
+
go build -o knot ./cmd/knotserver
46
+
go build -o keyfetch ./cmd/keyfetch
47
+
go build -o repoguard ./cmd/repoguard
48
+
```
49
+
50
+
Next, move the `keyfetch` binary to a location owned by `root` --
51
+
`/usr/local/libexec/tangled-keyfetch` is a good choice:
52
+
53
+
```
54
+
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
55
+
sudo chown root:root /usr/local/libexec/tangled-keyfetch
56
+
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
57
+
```
58
+
59
+
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
60
+
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
61
+
62
+
```
63
+
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
64
+
Match User git
65
+
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
66
+
AuthorizedKeysCommandUser nobody
67
+
EOF
68
+
```
69
+
70
+
Next, create the `git` user:
71
+
72
+
```
73
+
sudo adduser git
74
+
```
75
+
76
+
Copy the `repoguard` binary to the `git` user's home directory:
77
+
78
+
```
79
+
sudo cp repoguard /home/git
80
+
sudo chown git:git /home/git/repoguard
81
+
```
82
+
83
+
Now, let's set up the server. Copy the `knot` binary to
84
+
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
85
+
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
86
+
obtaind from the [/knots](/knots) page on Tangled.
87
+
88
+
```
89
+
KNOT_REPO_SCAN_PATH=/home/git
90
+
KNOT_SERVER_HOSTNAME=knot.example.com
91
+
APPVIEW_ENDPOINT=https://tangled.sh
92
+
KNOT_SERVER_SECRET=secret
93
+
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
94
+
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
95
+
```
96
+
97
+
If you run a Linux distribution that uses systemd, you can use the provided
98
+
service file to run the server. Copy
99
+
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
100
+
to `/etc/systemd/system/`. Then, run:
101
+
102
+
```
103
+
systemctl enable knotserver
104
+
systemctl start knotserver
105
+
```
106
+
107
+
You should now have a running knot server! You can finalize your registration by hitting the
108
+
`initialize` button on the [/knots](/knots) page.
+28
-17
flake.lock
+28
-17
flake.lock
···
32
32
"url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"
33
33
}
34
34
},
35
-
"ia-fonts-src": {
35
+
"ibm-plex-mono-src": {
36
36
"flake": false,
37
37
"locked": {
38
-
"lastModified": 1686932517,
39
-
"narHash": "sha256-2T165nFfCzO65/PIHauJA//S+zug5nUwPcg8NUEydfc=",
40
-
"owner": "iaolo",
41
-
"repo": "iA-Fonts",
42
-
"rev": "f32c04c3058a75d7ce28919ce70fe8800817491b",
43
-
"type": "github"
38
+
"lastModified": 1731402384,
39
+
"narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=",
40
+
"type": "tarball",
41
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
44
42
},
45
43
"original": {
46
-
"owner": "iaolo",
47
-
"repo": "iA-Fonts",
48
-
"type": "github"
44
+
"type": "tarball",
45
+
"url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip"
49
46
}
50
47
},
51
48
"indigo": {
52
49
"flake": false,
53
50
"locked": {
54
-
"lastModified": 1738491661,
55
-
"narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=",
51
+
"lastModified": 1745333930,
52
+
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
56
53
"owner": "oppiliappan",
57
54
"repo": "indigo",
58
-
"rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71",
55
+
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
59
56
"type": "github"
60
57
},
61
58
"original": {
···
64
61
"type": "github"
65
62
}
66
63
},
64
+
"inter-fonts-src": {
65
+
"flake": false,
66
+
"locked": {
67
+
"lastModified": 1731687360,
68
+
"narHash": "sha256-5vdKKvHAeZi6igrfpbOdhZlDX2/5+UvzlnCQV6DdqoQ=",
69
+
"type": "tarball",
70
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
71
+
},
72
+
"original": {
73
+
"type": "tarball",
74
+
"url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"
75
+
}
76
+
},
67
77
"lucide-src": {
68
78
"flake": false,
69
79
"locked": {
···
79
89
},
80
90
"nixpkgs": {
81
91
"locked": {
82
-
"lastModified": 1742268799,
83
-
"narHash": "sha256-IhnK4LhkBlf14/F8THvUy3xi/TxSQkp9hikfDZRD4Ic=",
92
+
"lastModified": 1743813633,
93
+
"narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=",
84
94
"owner": "nixos",
85
95
"repo": "nixpkgs",
86
-
"rev": "da044451c6a70518db5b730fe277b70f494188f1",
96
+
"rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6",
87
97
"type": "github"
88
98
},
89
99
"original": {
···
97
107
"inputs": {
98
108
"gitignore": "gitignore",
99
109
"htmx-src": "htmx-src",
100
-
"ia-fonts-src": "ia-fonts-src",
110
+
"ibm-plex-mono-src": "ibm-plex-mono-src",
101
111
"indigo": "indigo",
112
+
"inter-fonts-src": "inter-fonts-src",
102
113
"lucide-src": "lucide-src",
103
114
"nixpkgs": "nixpkgs"
104
115
}
+77
-33
flake.nix
+77
-33
flake.nix
···
15
15
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
16
16
flake = false;
17
17
};
18
-
ia-fonts-src = {
19
-
url = "github:iaolo/iA-Fonts";
18
+
inter-fonts-src = {
19
+
url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip";
20
+
flake = false;
21
+
};
22
+
ibm-plex-mono-src = {
23
+
url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip";
20
24
flake = false;
21
25
};
22
26
gitignore = {
···
32
36
htmx-src,
33
37
lucide-src,
34
38
gitignore,
35
-
ia-fonts-src,
39
+
inter-fonts-src,
40
+
ibm-plex-mono-src,
36
41
}: let
37
42
supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"];
38
43
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
···
44
49
inherit (gitignore.lib) gitignoreSource;
45
50
in {
46
51
overlays.default = final: prev: let
47
-
goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4=";
52
+
goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA=";
48
53
buildCmdPackage = name:
49
54
final.buildGoModule {
50
55
pname = name;
···
74
79
mkdir -p appview/pages/static/{fonts,icons}
75
80
cp -f ${htmx-src} appview/pages/static/htmx.min.js
76
81
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
77
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
78
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
82
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
83
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
84
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
79
85
${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
80
86
popd
81
87
'';
···
153
159
mkdir -p appview/pages/static/{fonts,icons}
154
160
cp -f ${htmx-src} appview/pages/static/htmx.min.js
155
161
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
156
-
cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/
157
-
cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/
162
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
163
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
164
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
158
165
'';
159
166
};
160
167
});
···
166
173
${pkgs.air}/bin/air -c /dev/null \
167
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" \
168
175
-build.bin "./out/${name}.out" \
169
-
-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
170
182
'';
171
183
in {
172
184
watch-appview = {
···
176
188
watch-knotserver = {
177
189
type = "app";
178
190
program = ''${air-watcher "knotserver"}/bin/run'';
191
+
};
192
+
watch-tailwind = {
193
+
type = "app";
194
+
program = ''${tailwind-watcher}/bin/run'';
179
195
};
180
196
});
181
197
···
230
246
pkgs,
231
247
lib,
232
248
...
233
-
}:
249
+
}: let
250
+
cfg = config.services.tangled-knotserver;
251
+
in
234
252
with lib; {
235
253
options = {
236
254
services.tangled-knotserver = {
···
252
270
description = "User that hosts git repos and performs git operations";
253
271
};
254
272
273
+
openFirewall = mkOption {
274
+
type = types.bool;
275
+
default = true;
276
+
description = "Open port 22 in the firewall for ssh";
277
+
};
278
+
279
+
stateDir = mkOption {
280
+
type = types.path;
281
+
default = "/home/${cfg.gitUser}";
282
+
description = "Tangled knot data directory";
283
+
};
284
+
255
285
repo = {
256
286
scanPath = mkOption {
257
287
type = types.path;
258
-
default = "/home/git";
288
+
default = cfg.stateDir;
259
289
description = "Path where repositories are scanned from";
260
290
};
261
291
···
287
317
288
318
dbPath = mkOption {
289
319
type = types.path;
290
-
default = "knotserver.db";
320
+
default = "${cfg.stateDir}/knotserver.db";
291
321
description = "Path to the database file";
292
322
};
293
323
···
306
336
};
307
337
};
308
338
309
-
config = mkIf config.services.tangled-knotserver.enable {
339
+
config = mkIf cfg.enable {
310
340
environment.systemPackages = with pkgs; [git];
311
341
312
342
system.activationScripts.gitConfig = ''
313
-
mkdir -p /home/git/.config/git
314
-
cat > /home/git/.config/git/config << EOF
343
+
mkdir -p "${cfg.repo.scanPath}"
344
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
345
+
"${cfg.repo.scanPath}"
346
+
347
+
mkdir -p "${cfg.stateDir}/.config/git"
348
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
315
349
[user]
316
350
name = Git User
317
351
email = git@example.com
318
352
EOF
319
-
chown -R git:git /home/git/.config
353
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
354
+
"${cfg.stateDir}"
320
355
'';
321
356
322
-
users.users.git = {
323
-
isNormalUser = true;
324
-
home = "/home/git";
357
+
users.users.${cfg.gitUser} = {
358
+
isSystemUser = true;
359
+
useDefaultShell = true;
360
+
home = cfg.stateDir;
325
361
createHome = true;
326
-
group = "git";
362
+
group = cfg.gitUser;
327
363
};
328
364
329
-
users.groups.git = {};
365
+
users.groups.${cfg.gitUser} = {};
330
366
331
367
services.openssh = {
332
368
enable = true;
333
369
extraConfig = ''
334
-
Match User git
370
+
Match User ${cfg.gitUser}
335
371
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
336
372
AuthorizedKeysCommandUser nobody
337
373
'';
···
343
379
#!${pkgs.stdenv.shell}
344
380
${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \
345
381
-repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \
382
+
-internal-api "http://${cfg.server.internalListenAddr}" \
383
+
-git-dir "${cfg.repo.scanPath}" \
346
384
-log-path /tmp/repoguard.log
347
385
'';
348
386
};
···
352
390
after = ["network.target" "sshd.service"];
353
391
wantedBy = ["multi-user.target"];
354
392
serviceConfig = {
355
-
User = "git";
356
-
WorkingDirectory = "/home/git";
393
+
User = cfg.gitUser;
394
+
WorkingDirectory = cfg.stateDir;
357
395
Environment = [
358
-
"KNOT_REPO_SCAN_PATH=${config.services.tangled-knotserver.repo.scanPath}"
359
-
"APPVIEW_ENDPOINT=${config.services.tangled-knotserver.appviewEndpoint}"
360
-
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${config.services.tangled-knotserver.server.internalListenAddr}"
361
-
"KNOT_SERVER_LISTEN_ADDR=${config.services.tangled-knotserver.server.listenAddr}"
362
-
"KNOT_SERVER_HOSTNAME=${config.services.tangled-knotserver.server.hostname}"
396
+
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
397
+
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
398
+
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
399
+
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
400
+
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
401
+
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
402
+
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
363
403
];
364
-
EnvironmentFile = config.services.tangled-knotserver.server.secretFile;
404
+
EnvironmentFile = cfg.server.secretFile;
365
405
ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver";
366
406
Restart = "always";
367
407
};
368
408
};
369
409
370
-
networking.firewall.allowedTCPPorts = [22];
410
+
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
371
411
};
372
412
};
373
413
···
384
424
virtualisation.cores = 2;
385
425
services.getty.autologinUser = "root";
386
426
environment.systemPackages = with pkgs; [curl vim git];
387
-
systemd.tmpfiles.rules = [
388
-
"w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85"
427
+
systemd.tmpfiles.rules = let
428
+
u = config.services.tangled-knotserver.gitUser;
429
+
g = config.services.tangled-knotserver.gitUser;
430
+
in [
431
+
"d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first
432
+
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=5b42390da4c6659f34c9a545adebd8af82c4a19960d735f651e3d582623ba9f2"
389
433
];
390
434
services.tangled-knotserver = {
391
435
enable = true;
+3
-3
go.mod
+3
-3
go.mod
···
26
26
github.com/sethvargo/go-envconfig v1.1.0
27
27
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
28
28
github.com/yuin/goldmark v1.4.13
29
-
golang.org/x/crypto v0.36.0
30
29
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
31
30
)
32
31
···
107
106
go.uber.org/atomic v1.11.0 // indirect
108
107
go.uber.org/multierr v1.11.0 // indirect
109
108
go.uber.org/zap v1.26.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=
+179
-82
input.css
+179
-82
input.css
···
3
3
@tailwind utilities;
4
4
@layer base {
5
5
@font-face {
6
-
font-family: "iA Writer Quattro S";
7
-
src: url("/static/fonts/iAWriterQuattroS-Regular.ttf")
8
-
format("truetype");
6
+
font-family: "InterVariable";
7
+
src: url("/static/fonts/InterVariable.woff2") format("woff2");
9
8
font-weight: normal;
10
9
font-style: normal;
11
10
font-display: swap;
12
-
font-feature-settings:
13
-
"calt" 1,
14
-
"kern" 1;
15
11
}
12
+
16
13
@font-face {
17
-
font-family: "iA Writer Quattro S";
18
-
src: url("/static/fonts/iAWriterQuattroS-Bold.ttf") format("truetype");
19
-
font-weight: bold;
20
-
font-style: normal;
21
-
font-display: swap;
22
-
font-feature-settings:
23
-
"calt" 1,
24
-
"kern" 1;
25
-
}
26
-
@font-face {
27
-
font-family: "iA Writer Quattro S";
28
-
src: url("/static/fonts/iAWriterQuattroS-Italic.ttf") format("truetype");
14
+
font-family: "InterVariable";
15
+
src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2");
29
16
font-weight: normal;
30
17
font-style: italic;
31
18
font-display: swap;
32
-
font-feature-settings:
33
-
"calt" 1,
34
-
"kern" 1;
35
-
}
36
-
@font-face {
37
-
font-family: "iA Writer Quattro S";
38
-
src: url("/static/fonts/iAWriterQuattroS-BoldItalic.ttf")
39
-
format("truetype");
40
-
font-weight: bold;
41
-
font-style: italic;
42
-
font-display: swap;
43
-
font-feature-settings:
44
-
"calt" 1,
45
-
"kern" 1;
46
19
}
47
20
48
21
@font-face {
49
-
font-family: "iA Writer Mono S";
50
-
src: url("/static/fonts/iAWriterMonoS-Regular.ttf") format("truetype");
51
-
font-weight: normal;
52
-
font-style: normal;
53
-
font-display: swap;
54
-
font-feature-settings:
55
-
"calt" 1,
56
-
"kern" 1;
57
-
}
58
-
@font-face {
59
-
font-family: "iA Writer Mono S";
60
-
src: url("/static/fonts/iAWriterMonoS-Bold.ttf") format("truetype");
61
-
font-weight: bold;
62
-
font-style: normal;
63
-
font-display: swap;
64
-
font-feature-settings:
65
-
"calt" 1,
66
-
"kern" 1;
67
-
}
68
-
@font-face {
69
-
font-family: "iA Writer Mono S";
70
-
src: url("/static/fonts/iAWriterMonoS-Italic.ttf") format("truetype");
22
+
font-family: "IBMPlexMono";
23
+
src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2");
71
24
font-weight: normal;
72
25
font-style: italic;
73
26
font-display: swap;
74
-
font-feature-settings:
75
-
"calt" 1,
76
-
"kern" 1;
77
-
}
78
-
@font-face {
79
-
font-family: "iA Writer Mono S";
80
-
src: url("/static/fonts/iAWriterMonoS-BoldItalic.ttf")
81
-
format("truetype");
82
-
font-weight: bold;
83
-
font-style: italic;
84
-
font-display: swap;
85
-
font-feature-settings:
86
-
"calt" 1,
87
-
"kern" 1;
88
27
}
89
28
90
-
@font-face {
91
-
font-family: "Inter";
92
-
font-style: normal;
93
-
font-weight: 400;
94
-
font-display: swap;
95
-
font-feature-settings:
96
-
"calt" 1,
97
-
"kern" 1;
98
-
}
99
29
::selection {
100
30
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
101
31
}
102
32
103
33
@layer base {
104
34
html {
105
-
letter-spacing: -0.01em;
106
-
word-spacing: -0.07em;
107
-
font-size: 14px;
35
+
font-size: 15px;
108
36
}
37
+
@supports (font-variation-settings: normal) {
38
+
html {
39
+
font-feature-settings:
40
+
"ss01" 1,
41
+
"kern" 1,
42
+
"liga" 1,
43
+
"cv05" 1,
44
+
"tnum" 1;
45
+
}
46
+
}
47
+
109
48
a {
110
49
@apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300;
111
50
}
···
114
53
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
115
54
}
116
55
input {
117
-
@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;
118
57
}
119
58
textarea {
120
-
@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;
121
60
}
122
61
details summary::-webkit-details-marker {
123
62
display: none;
···
156
95
}
157
96
}
158
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
+
}
+112
-10
knotserver/git/diff.go
+112
-10
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"
12
+
"github.com/go-git/go-git/v5/plumbing"
9
13
"github.com/go-git/go-git/v5/plumbing/object"
14
+
"tangled.sh/tangled.sh/core/patchutil"
10
15
"tangled.sh/tangled.sh/core/types"
11
16
)
12
17
···
46
51
}
47
52
48
53
nd := types.NiceDiff{}
49
-
nd.Commit.This = c.Hash.String()
50
-
51
-
if parent.Hash.IsZero() {
52
-
nd.Commit.Parent = ""
53
-
} else {
54
-
nd.Commit.Parent = parent.Hash.String()
55
-
}
56
-
nd.Commit.Author = c.Author
57
-
nd.Commit.Message = c.Message
58
-
59
54
for _, d := range diffs {
60
55
ndiff := types.Diff{}
61
56
ndiff.Name.New = d.NewName
···
82
77
}
83
78
84
79
nd.Stat.FilesChanged = len(diffs)
80
+
nd.Commit.This = c.Hash.String()
81
+
82
+
if parent.Hash.IsZero() {
83
+
nd.Commit.Parent = ""
84
+
} else {
85
+
nd.Commit.Parent = parent.Hash.String()
86
+
}
87
+
nd.Commit.Author = c.Author
88
+
nd.Commit.Message = c.Message
85
89
86
90
return &nd, nil
87
91
}
92
+
93
+
func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) {
94
+
tree1, err := commit1.Tree()
95
+
if err != nil {
96
+
return nil, err
97
+
}
98
+
99
+
tree2, err := commit2.Tree()
100
+
if err != nil {
101
+
return nil, err
102
+
}
103
+
104
+
diff, err := object.DiffTree(tree1, tree2)
105
+
if err != nil {
106
+
return nil, err
107
+
}
108
+
109
+
patch, err := diff.Patch()
110
+
if err != nil {
111
+
return nil, err
112
+
}
113
+
114
+
diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String()))
115
+
if err != nil {
116
+
return nil, err
117
+
}
118
+
119
+
return &types.DiffTree{
120
+
Rev1: commit1.Hash.String(),
121
+
Rev2: commit2.Hash.String(),
122
+
Patch: patch.String(),
123
+
Diff: diffs,
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
152
+
}
153
+
154
+
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
155
+
isAncestor, err := commit1.IsAncestor(commit2)
156
+
if err != nil {
157
+
return nil, err
158
+
}
159
+
160
+
if isAncestor {
161
+
return commit1, nil
162
+
}
163
+
164
+
mergeBase, err := commit1.MergeBase(commit2)
165
+
if err != nil {
166
+
return nil, err
167
+
}
168
+
169
+
if len(mergeBase) == 0 {
170
+
return nil, fmt.Errorf("failed to find a merge-base")
171
+
}
172
+
173
+
return mergeBase[0], nil
174
+
}
175
+
176
+
func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) {
177
+
rev, err := g.r.ResolveRevision(plumbing.Revision(revStr))
178
+
if err != nil {
179
+
return nil, fmt.Errorf("resolving revision %s: %w", revStr, err)
180
+
}
181
+
182
+
commit, err := g.r.CommitObject(*rev)
183
+
if err != nil {
184
+
185
+
return nil, fmt.Errorf("getting commit for %s: %w", revStr, err)
186
+
}
187
+
188
+
return commit, nil
189
+
}
+50
knotserver/git/fork.go
+50
knotserver/git/fork.go
···
1
+
package git
2
+
3
+
import (
4
+
"errors"
5
+
"fmt"
6
+
"os/exec"
7
+
8
+
"github.com/go-git/go-git/v5"
9
+
"github.com/go-git/go-git/v5/config"
10
+
)
11
+
12
+
func Fork(repoPath, source string) error {
13
+
_, err := git.PlainClone(repoPath, true, &git.CloneOptions{
14
+
URL: source,
15
+
SingleBranch: false,
16
+
})
17
+
18
+
if err != nil {
19
+
return fmt.Errorf("failed to bare clone repository: %w", err)
20
+
}
21
+
22
+
err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run()
23
+
if err != nil {
24
+
return fmt.Errorf("failed to configure hidden refs: %w", err)
25
+
}
26
+
27
+
return nil
28
+
}
29
+
30
+
// TrackHiddenRemoteRef tracks a hidden remote in the repository. For example,
31
+
// if the feature branch on the fork (forkRef) is feature-1, and the remoteRef,
32
+
// i.e. the branch we want to merge into, is main, this will result in a refspec:
33
+
//
34
+
// +refs/heads/main:refs/hidden/feature-1/main
35
+
func (g *GitRepo) TrackHiddenRemoteRef(forkRef, remoteRef string) error {
36
+
fetchOpts := &git.FetchOptions{
37
+
RefSpecs: []config.RefSpec{
38
+
config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/hidden/%s/%s", remoteRef, forkRef, remoteRef)),
39
+
},
40
+
RemoteName: "origin",
41
+
}
42
+
43
+
err := g.r.Fetch(fetchOpts)
44
+
if errors.Is(git.NoErrAlreadyUpToDate, err) {
45
+
return nil
46
+
} else if err != nil {
47
+
return fmt.Errorf("failed to fetch hidden remote: %s: %w", forkRef, err)
48
+
}
49
+
return nil
50
+
}
+28
knotserver/git/git.go
+28
knotserver/git/git.go
···
131
131
return &g, nil
132
132
}
133
133
134
+
func PlainOpen(path string) (*GitRepo, error) {
135
+
var err error
136
+
g := GitRepo{path: path}
137
+
g.r, err = git.PlainOpen(path)
138
+
if err != nil {
139
+
return nil, fmt.Errorf("opening %s: %w", path, err)
140
+
}
141
+
return &g, nil
142
+
}
143
+
134
144
func (g *GitRepo) Commits() ([]*object.Commit, error) {
135
145
ci, err := g.r.Log(&git.LogOptions{From: g.h})
136
146
if err != nil {
···
226
236
})
227
237
228
238
return branches, nil
239
+
}
240
+
241
+
func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
242
+
ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
243
+
if err != nil {
244
+
return nil, fmt.Errorf("branch: %w", err)
245
+
}
246
+
247
+
if !ref.Name().IsBranch() {
248
+
return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
249
+
}
250
+
251
+
return ref, nil
252
+
}
253
+
254
+
func (g *GitRepo) SetDefaultBranch(branch string) error {
255
+
ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
256
+
return g.r.Storer.SetReference(ref)
229
257
}
230
258
231
259
func (g *GitRepo) FindMainBranch() (string, 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 {
+13
-1
knotserver/handler.go
+13
-1
knotserver/handler.go
···
70
70
}
71
71
72
72
r.Get("/", h.Index)
73
+
r.Get("/capabilities", h.Capabilities)
73
74
r.Get("/version", h.Version)
74
75
r.Route("/{did}", func(r chi.Router) {
75
76
// Repo routes
···
82
83
r.Get("/", h.RepoIndex)
83
84
r.Get("/info/refs", h.InfoRefs)
84
85
r.Post("/git-upload-pack", h.UploadPack)
86
+
r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects
87
+
88
+
r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef)
85
89
86
90
r.Route("/merge", func(r chi.Router) {
87
91
r.With(h.VerifySignature)
···
102
106
r.Get("/archive/{file}", h.Archive)
103
107
r.Get("/commit/{ref}", h.Diff)
104
108
r.Get("/tags", h.Tags)
105
-
r.Get("/branches", h.Branches)
109
+
r.Route("/branches", func(r chi.Router) {
110
+
r.Get("/", h.Branches)
111
+
r.Get("/{branch}", h.Branch)
112
+
r.Route("/default", func(r chi.Router) {
113
+
r.Get("/", h.DefaultBranch)
114
+
r.With(h.VerifySignature).Put("/", h.SetDefaultBranch)
115
+
})
116
+
})
106
117
})
107
118
})
108
119
···
111
122
r.Use(h.VerifySignature)
112
123
r.Put("/new", h.NewRepo)
113
124
r.Delete("/", h.RemoveRepo)
125
+
r.Post("/fork", h.RepoFork)
114
126
})
115
127
116
128
r.Route("/member", func(r chi.Router) {
+234
knotserver/routes.go
+234
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
30
31
func (h *Handle) Index(w http.ResponseWriter, r *http.Request) {
31
32
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
33
+
}
34
+
35
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
36
+
w.Header().Set("Content-Type", "application/json")
37
+
38
+
capabilities := map[string]any{
39
+
"pull_requests": map[string]any{
40
+
"format_patch": true,
41
+
"patch_submissions": true,
42
+
"branch_submissions": true,
43
+
"fork_submissions": true,
44
+
},
45
+
}
46
+
47
+
jsonData, err := json.Marshal(capabilities)
48
+
if err != nil {
49
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
50
+
return
51
+
}
52
+
53
+
w.Write(jsonData)
32
54
}
33
55
34
56
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
···
436
458
return
437
459
}
438
460
461
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
462
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
463
+
branchName := chi.URLParam(r, "branch")
464
+
l := h.l.With("handler", "Branch")
465
+
466
+
gr, err := git.PlainOpen(path)
467
+
if err != nil {
468
+
notFound(w)
469
+
return
470
+
}
471
+
472
+
ref, err := gr.Branch(branchName)
473
+
if err != nil {
474
+
l.Error("getting branches", "error", err.Error())
475
+
writeError(w, err.Error(), http.StatusInternalServerError)
476
+
return
477
+
}
478
+
479
+
resp := types.RepoBranchResponse{
480
+
Branch: types.Branch{
481
+
Reference: types.Reference{
482
+
Name: ref.Name().Short(),
483
+
Hash: ref.Hash().String(),
484
+
},
485
+
},
486
+
}
487
+
488
+
writeJSON(w, resp)
489
+
return
490
+
}
491
+
439
492
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
440
493
l := h.l.With("handler", "Keys")
441
494
···
526
579
w.WriteHeader(http.StatusNoContent)
527
580
}
528
581
582
+
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
583
+
l := h.l.With("handler", "RepoFork")
584
+
585
+
data := struct {
586
+
Did string `json:"did"`
587
+
Source string `json:"source"`
588
+
Name string `json:"name,omitempty"`
589
+
}{}
590
+
591
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
592
+
writeError(w, "invalid request body", http.StatusBadRequest)
593
+
return
594
+
}
595
+
596
+
did := data.Did
597
+
source := data.Source
598
+
599
+
if did == "" || source == "" {
600
+
l.Error("invalid request body, empty did or name")
601
+
w.WriteHeader(http.StatusBadRequest)
602
+
return
603
+
}
604
+
605
+
var name string
606
+
if data.Name != "" {
607
+
name = data.Name
608
+
} else {
609
+
name = filepath.Base(source)
610
+
}
611
+
612
+
relativeRepoPath := filepath.Join(did, name)
613
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
614
+
615
+
err := git.Fork(repoPath, source)
616
+
if err != nil {
617
+
l.Error("forking repo", "error", err.Error())
618
+
writeError(w, err.Error(), http.StatusInternalServerError)
619
+
return
620
+
}
621
+
622
+
// add perms for this user to access the repo
623
+
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
624
+
if err != nil {
625
+
l.Error("adding repo permissions", "error", err.Error())
626
+
writeError(w, err.Error(), http.StatusInternalServerError)
627
+
return
628
+
}
629
+
630
+
w.WriteHeader(http.StatusNoContent)
631
+
}
632
+
529
633
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
530
634
l := h.l.With("handler", "RemoveRepo")
531
635
···
585
689
notFound(w)
586
690
return
587
691
}
692
+
693
+
mo.FormatPatch = patchutil.IsFormatPatch(patch)
694
+
588
695
if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil {
589
696
var mergeErr *git.ErrMerge
590
697
if errors.As(err, &mergeErr) {
···
665
772
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
666
773
}
667
774
775
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
776
+
rev1 := chi.URLParam(r, "rev1")
777
+
rev1, _ = url.PathUnescape(rev1)
778
+
779
+
rev2 := chi.URLParam(r, "rev2")
780
+
rev2, _ = url.PathUnescape(rev2)
781
+
782
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
783
+
784
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
785
+
gr, err := git.PlainOpen(path)
786
+
if err != nil {
787
+
notFound(w)
788
+
return
789
+
}
790
+
791
+
commit1, err := gr.ResolveRevision(rev1)
792
+
if err != nil {
793
+
l.Error("error resolving revision 1", "msg", err.Error())
794
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
795
+
return
796
+
}
797
+
798
+
commit2, err := gr.ResolveRevision(rev2)
799
+
if err != nil {
800
+
l.Error("error resolving revision 2", "msg", err.Error())
801
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
802
+
return
803
+
}
804
+
805
+
mergeBase, err := gr.MergeBase(commit1, commit2)
806
+
if err != nil {
807
+
l.Error("failed to find merge-base", "msg", err.Error())
808
+
writeError(w, "failed to calculate diff", http.StatusBadRequest)
809
+
return
810
+
}
811
+
812
+
rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2)
813
+
if err != nil {
814
+
l.Error("error comparing revisions", "msg", err.Error())
815
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
816
+
return
817
+
}
818
+
819
+
writeJSON(w, types.RepoFormatPatchResponse{
820
+
Rev1: commit1.Hash.String(),
821
+
Rev2: commit2.Hash.String(),
822
+
FormatPatch: formatPatch,
823
+
Patch: rawPatch,
824
+
})
825
+
return
826
+
}
827
+
828
+
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
829
+
l := h.l.With("handler", "NewHiddenRef")
830
+
831
+
forkRef := chi.URLParam(r, "forkRef")
832
+
remoteRef := chi.URLParam(r, "remoteRef")
833
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
834
+
gr, err := git.PlainOpen(path)
835
+
if err != nil {
836
+
notFound(w)
837
+
return
838
+
}
839
+
840
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
841
+
if err != nil {
842
+
l.Error("error tracking hidden remote ref", "msg", err.Error())
843
+
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
844
+
return
845
+
}
846
+
847
+
w.WriteHeader(http.StatusNoContent)
848
+
return
849
+
}
850
+
668
851
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
669
852
l := h.l.With("handler", "AddMember")
670
853
···
733
916
if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil {
734
917
l.Error("fetching and adding keys", "error", err.Error())
735
918
writeError(w, err.Error(), http.StatusInternalServerError)
919
+
return
920
+
}
921
+
922
+
w.WriteHeader(http.StatusNoContent)
923
+
}
924
+
925
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
926
+
l := h.l.With("handler", "DefaultBranch")
927
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
928
+
929
+
gr, err := git.Open(path, "")
930
+
if err != nil {
931
+
notFound(w)
932
+
return
933
+
}
934
+
935
+
branch, err := gr.FindMainBranch()
936
+
if err != nil {
937
+
writeError(w, err.Error(), http.StatusInternalServerError)
938
+
l.Error("getting default branch", "error", err.Error())
939
+
return
940
+
}
941
+
942
+
writeJSON(w, types.RepoDefaultBranchResponse{
943
+
Branch: branch,
944
+
})
945
+
}
946
+
947
+
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
948
+
l := h.l.With("handler", "SetDefaultBranch")
949
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
950
+
951
+
data := struct {
952
+
Branch string `json:"branch"`
953
+
}{}
954
+
955
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
956
+
writeError(w, err.Error(), http.StatusBadRequest)
957
+
return
958
+
}
959
+
960
+
gr, err := git.Open(path, "")
961
+
if err != nil {
962
+
notFound(w)
963
+
return
964
+
}
965
+
966
+
err = gr.SetDefaultBranch(data.Branch)
967
+
if err != nil {
968
+
writeError(w, err.Error(), http.StatusInternalServerError)
969
+
l.Error("setting default branch", "error", err.Error())
736
970
return
737
971
}
738
972
+24
-5
lexicons/pulls/pull.json
+24
-5
lexicons/pulls/pull.json
···
9
9
"key": "tid",
10
10
"record": {
11
11
"type": "object",
12
-
"required": ["targetRepo", "targetBranch", "pullId", "title", "patch"],
12
+
"required": [
13
+
"targetRepo",
14
+
"targetBranch",
15
+
"pullId",
16
+
"title",
17
+
"patch"
18
+
],
13
19
"properties": {
14
20
"targetRepo": {
15
21
"type": "string",
···
18
24
"targetBranch": {
19
25
"type": "string"
20
26
},
21
-
"sourceRepo": {
22
-
"type": "string",
23
-
"format": "at-uri"
24
-
},
25
27
"pullId": {
26
28
"type": "integer"
27
29
},
···
37
39
},
38
40
"patch": {
39
41
"type": "string"
42
+
},
43
+
"source": {
44
+
"type": "ref",
45
+
"ref": "#source"
40
46
}
47
+
}
48
+
}
49
+
},
50
+
"source": {
51
+
"type": "object",
52
+
"required": ["branch"],
53
+
"properties": {
54
+
"branch": {
55
+
"type": "string"
56
+
},
57
+
"repo": {
58
+
"type": "string",
59
+
"format": "at-uri"
41
60
}
42
61
}
43
62
}
+5
lexicons/repo.json
+5
lexicons/repo.json
+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
+
}
+55
-34
rbac/rbac.go
+55
-34
rbac/rbac.go
···
3
3
import (
4
4
"database/sql"
5
5
"fmt"
6
-
"path"
7
6
"strings"
8
7
9
8
adapter "github.com/Blank-Xu/sql-adapter"
···
26
25
e = some(where (p.eft == allow))
27
26
28
27
[matchers]
29
-
m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom)
28
+
m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom)
30
29
`
31
30
)
32
31
···
34
33
E *casbin.Enforcer
35
34
}
36
35
37
-
func keyMatch2(key1 string, key2 string) bool {
38
-
matched, _ := path.Match(key2, key1)
39
-
return matched
40
-
}
41
-
42
36
func NewEnforcer(path string) (*Enforcer, error) {
43
37
m, err := model.NewModelFromString(Model)
44
38
if err != nil {
···
61
55
}
62
56
63
57
e.EnableAutoSave(false)
64
-
65
-
e.AddFunction("keyMatch2", keyMatch2Func)
66
58
67
59
return &Enforcer{e}, nil
68
60
}
···
96
88
return err
97
89
}
98
90
99
-
func (e *Enforcer) AddRepo(member, domain, repo string) error {
100
-
// sanity check, repo must be of the form ownerDid/repo
101
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
102
-
return fmt.Errorf("invalid repo: %s", repo)
103
-
}
104
-
105
-
_, err := e.E.AddPolicies([][]string{
91
+
func repoPolicies(member, domain, repo string) [][]string {
92
+
return [][]string{
106
93
{member, domain, repo, "repo:settings"},
107
94
{member, domain, repo, "repo:push"},
108
95
{member, domain, repo, "repo:owner"},
109
96
{member, domain, repo, "repo:invite"},
110
97
{member, domain, repo, "repo:delete"},
111
98
{"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo
112
-
})
99
+
}
100
+
}
101
+
func (e *Enforcer) AddRepo(member, domain, repo string) error {
102
+
err := checkRepoFormat(repo)
103
+
if err != nil {
104
+
return err
105
+
}
106
+
107
+
_, err = e.E.AddPolicies(repoPolicies(member, domain, repo))
113
108
return err
114
109
}
110
+
func (e *Enforcer) RemoveRepo(member, domain, repo string) error {
111
+
err := checkRepoFormat(repo)
112
+
if err != nil {
113
+
return err
114
+
}
115
+
116
+
_, err = e.E.RemovePolicies(repoPolicies(member, domain, repo))
117
+
return err
118
+
}
119
+
120
+
var (
121
+
collaboratorPolicies = func(collaborator, domain, repo string) [][]string {
122
+
return [][]string{
123
+
{collaborator, domain, repo, "repo:collaborator"},
124
+
{collaborator, domain, repo, "repo:settings"},
125
+
{collaborator, domain, repo, "repo:push"},
126
+
}
127
+
}
128
+
)
115
129
116
130
func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error {
117
-
// sanity check, repo must be of the form ownerDid/repo
118
-
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
119
-
return fmt.Errorf("invalid repo: %s", repo)
131
+
err := checkRepoFormat(repo)
132
+
if err != nil {
133
+
return err
120
134
}
121
135
122
-
_, err := e.E.AddPolicies([][]string{
123
-
{collaborator, domain, repo, "repo:collaborator"},
124
-
{collaborator, domain, repo, "repo:settings"},
125
-
{collaborator, domain, repo, "repo:push"},
126
-
})
136
+
_, err = e.E.AddPolicies(collaboratorPolicies(collaborator, domain, repo))
137
+
return err
138
+
}
139
+
140
+
func (e *Enforcer) RemoveCollaborator(collaborator, domain, repo string) error {
141
+
err := checkRepoFormat(repo)
142
+
if err != nil {
143
+
return err
144
+
}
145
+
146
+
_, err = e.E.RemovePolicies(collaboratorPolicies(collaborator, domain, repo))
127
147
return err
128
148
}
129
149
···
165
185
return e.E.Enforce(user, domain, repo, "repo:settings")
166
186
}
167
187
188
+
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
189
+
return e.E.Enforce(user, domain, repo, "repo:invite")
190
+
}
191
+
168
192
// given a repo, what permissions does this user have? repo:owner? repo:invite? etc.
169
193
func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string {
170
194
var permissions []string
···
179
203
return permissions
180
204
}
181
205
182
-
func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) {
183
-
return e.E.Enforce(user, domain, repo, "repo:invite")
184
-
}
206
+
func checkRepoFormat(repo string) error {
207
+
// sanity check, repo must be of the form ownerDid/repo
208
+
if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") {
209
+
return fmt.Errorf("invalid repo: %s", repo)
210
+
}
185
211
186
-
// keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin
187
-
func keyMatch2Func(args ...interface{}) (interface{}, error) {
188
-
name1 := args[0].(string)
189
-
name2 := args[1].(string)
190
-
191
-
return keyMatch2(name1, name2), nil
212
+
return nil
192
213
}
+8
-89
readme.md
+8
-89
readme.md
···
6
6
7
7
Read the introduction to Tangled [here](https://blog.tangled.sh/intro).
8
8
9
-
## knot self-hosting guide
9
+
## docs
10
10
11
-
So you want to run your own knot server? Great! Here are a few prerequisites:
11
+
* [knot hosting
12
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md)
13
+
* [contributing
14
+
guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)—**read this before opening a PR!**
12
15
13
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
14
-
2. A (sub)domain name. People generally use `knot.example.com`.
15
-
3. A valid SSL certificate for your domain.
16
+
## security
16
17
17
-
There's a couple of ways to get started:
18
-
* NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix)
19
-
* Manual: Documented below.
20
-
21
-
### manual setup
22
-
23
-
First, clone this repository:
24
-
25
-
```
26
-
git clone https://tangled.sh/@tangled.sh/core
27
-
```
28
-
29
-
Then, build our binaries (you need to have Go installed):
30
-
* `knotserver`: the main server program
31
-
* `keyfetch`: utility to fetch ssh pubkeys
32
-
* `repoguard`: enforces repository access control
33
-
34
-
```
35
-
cd core
36
-
export CGO_ENABLED=1
37
-
go build -o knot ./cmd/knotserver
38
-
go build -o keyfetch ./cmd/keyfetch
39
-
go build -o repoguard ./cmd/repoguard
40
-
```
41
-
42
-
Next, move the `keyfetch` binary to a location owned by `root` --
43
-
`/usr/local/libexec/tangled-keyfetch` is a good choice:
44
-
45
-
```
46
-
sudo mv keyfetch /usr/local/libexec/tangled-keyfetch
47
-
sudo chown root:root /usr/local/libexec/tangled-keyfetch
48
-
sudo chmod 755 /usr/local/libexec/tangled-keyfetch
49
-
```
50
-
51
-
This is necessary because SSH `AuthorizedKeysCommand` requires [really specific
52
-
permissions](https://stackoverflow.com/a/27638306). Let's set that up:
53
-
54
-
```
55
-
sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF
56
-
Match User git
57
-
AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch
58
-
AuthorizedKeysCommandUser nobody
59
-
EOF
60
-
```
61
-
62
-
Next, create the `git` user:
63
-
64
-
```
65
-
sudo adduser git
66
-
```
67
-
68
-
Copy the `repoguard` binary to the `git` user's home directory:
69
-
70
-
```
71
-
sudo cp repoguard /home/git
72
-
sudo chown git:git /home/git/repoguard
73
-
```
74
-
75
-
Now, let's set up the server. Copy the `knot` binary to
76
-
`/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the
77
-
following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be
78
-
obtaind from the [/knots](/knots) page on Tangled.
79
-
80
-
```
81
-
KNOT_REPO_SCAN_PATH=/home/git
82
-
KNOT_SERVER_HOSTNAME=knot.example.com
83
-
APPVIEW_ENDPOINT=https://tangled.sh
84
-
KNOT_SERVER_SECRET=secret
85
-
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
86
-
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
87
-
```
88
-
89
-
If you run a Linux distribution that uses systemd, you can use the provided
90
-
service file to run the server. Copy
91
-
[`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service)
92
-
to `/etc/systemd/system/`. Then, run:
93
-
94
-
```
95
-
systemctl enable knotserver
96
-
systemctl start knotserver
97
-
```
98
-
99
-
You should now have a running knot server! You can finalize your registration by hitting the
100
-
`initialize` button on the [/knots](/knots) page.
18
+
If you've identified a security issue in Tangled, please email
19
+
[security@tangled.sh](mailto:security@tangled.sh) with details!
+40
-8
tailwind.config.js
+40
-8
tailwind.config.js
···
13
13
md: "600px",
14
14
lg: "800px",
15
15
xl: "1000px",
16
-
"2xl": "1200px"
16
+
"2xl": "1200px",
17
17
},
18
18
},
19
19
extend: {
20
20
fontFamily: {
21
-
sans: ["iA Writer Quattro S", "Inter", "system-ui", "sans-serif", "ui-sans-serif"],
22
-
mono: ["iA Writer Mono S", "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "monospace"],
21
+
sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"],
22
+
mono: [
23
+
"IBMPlexMono",
24
+
"ui-monospace",
25
+
"SFMono-Regular",
26
+
"Menlo",
27
+
"Monaco",
28
+
"Consolas",
29
+
"Liberation Mono",
30
+
"Courier New",
31
+
"monospace",
32
+
],
23
33
},
24
34
typography: {
25
35
DEFAULT: {
26
36
css: {
27
-
maxWidth: 'none',
37
+
maxWidth: "none",
28
38
pre: {
29
39
backgroundColor: colors.gray[100],
30
40
color: colors.black,
31
-
'@apply dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border': {}
41
+
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
42
+
},
43
+
code: {
44
+
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
45
+
},
46
+
"code::before": {
47
+
content: '""',
48
+
},
49
+
"code::after": {
50
+
content: '""',
51
+
},
52
+
blockquote: {
53
+
quotes: "none",
54
+
},
55
+
'h1, h2, h3, h4': {
56
+
"@apply mt-4 mb-2": {}
57
+
},
58
+
h1: {
59
+
"@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {}
60
+
},
61
+
h2: {
62
+
"@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {}
63
+
},
64
+
h3: {
65
+
"@apply mt-2": {}
32
66
},
33
67
},
34
68
},
35
69
},
36
70
},
37
71
},
38
-
plugins: [
39
-
require('@tailwindcss/typography'),
40
-
]
72
+
plugins: [require("@tailwindcss/typography")],
41
73
};
+10
types/capabilities.go
+10
types/capabilities.go
···
1
+
package types
2
+
3
+
type Capabilities struct {
4
+
PullRequests struct {
5
+
FormatPatch bool `json:"format_patch"`
6
+
PatchSubmissions bool `json:"patch_submissions"`
7
+
BranchSubmissions bool `json:"branch_submissions"`
8
+
ForkSubmissions bool `json:"fork_submissions"`
9
+
} `json:"pull_requests"`
10
+
}
+35
types/diff.go
+35
types/diff.go
···
23
23
IsRename bool `json:"is_rename"`
24
24
}
25
25
26
+
type DiffStat struct {
27
+
Insertions int64
28
+
Deletions int64
29
+
}
30
+
31
+
func (d *Diff) Stats() DiffStat {
32
+
var stats DiffStat
33
+
for _, f := range d.TextFragments {
34
+
stats.Insertions += f.LinesAdded
35
+
stats.Deletions += f.LinesDeleted
36
+
}
37
+
return stats
38
+
}
39
+
26
40
// A nicer git diff representation.
27
41
type NiceDiff struct {
28
42
Commit struct {
···
38
52
} `json:"stat"`
39
53
Diff []Diff `json:"diff"`
40
54
}
55
+
56
+
type DiffTree struct {
57
+
Rev1 string `json:"rev1"`
58
+
Rev2 string `json:"rev2"`
59
+
Patch string `json:"patch"`
60
+
Diff []*gitdiff.File `json:"diff"`
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
+
}
+16
types/repo.go
+16
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 {
···
30
31
type RepoCommitResponse struct {
31
32
Ref string `json:"ref,omitempty"`
32
33
Diff *NiceDiff `json:"diff,omitempty"`
34
+
}
35
+
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"`
33
41
}
34
42
35
43
type RepoTreeResponse struct {
···
61
69
62
70
type RepoBranchesResponse struct {
63
71
Branches []Branch `json:"branches,omitempty"`
72
+
}
73
+
74
+
type RepoBranchResponse struct {
75
+
Branch Branch `json:"branch,omitempty"`
76
+
}
77
+
78
+
type RepoDefaultBranchResponse struct {
79
+
Branch string `json:"branch,omitempty"`
64
80
}
65
81
66
82
type RepoBlobResponse struct {