+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
+7
-1
appview/auth/auth.go
+7
-1
appview/auth/auth.go
···
128
128
}
129
129
130
130
func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error {
131
-
clientSession, _ := a.Store.Get(r, appview.SessionName)
131
+
clientSession, err := a.Store.Get(r, appview.SessionName)
132
+
if err != nil {
133
+
return fmt.Errorf("invalid session", err)
134
+
}
135
+
if clientSession.IsNew {
136
+
return fmt.Errorf("invalid session")
137
+
}
132
138
clientSession.Options.MaxAge = -1
133
139
return clientSession.Save(r, w)
134
140
}
+32
appview/db/db.go
+32
appview/db/db.go
···
248
248
return nil
249
249
})
250
250
251
+
runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error {
252
+
_, err := tx.Exec(`
253
+
alter table comments drop column comment_at;
254
+
alter table comments add column rkey text;
255
+
`)
256
+
return err
257
+
})
258
+
259
+
runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error {
260
+
_, err := tx.Exec(`
261
+
alter table comments add column deleted text; -- timestamp
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;
279
+
`)
280
+
return err
281
+
})
282
+
251
283
return &DB{db}, nil
252
284
}
253
285
+209
-11
appview/db/issues.go
+209
-11
appview/db/issues.go
···
12
12
OwnerDid string
13
13
IssueId int
14
14
IssueAt string
15
-
Created *time.Time
15
+
Created time.Time
16
16
Title string
17
17
Body string
18
18
Open bool
19
+
20
+
// optionally, populate this when querying for reverse mappings
21
+
// like comment counts, parent repo etc.
19
22
Metadata *IssueMetadata
20
23
}
21
24
22
25
type IssueMetadata struct {
23
26
CommentCount int
27
+
Repo *Repo
24
28
// labels, assignee etc.
25
29
}
26
30
27
31
type Comment struct {
28
32
OwnerDid string
29
33
RepoAt syntax.ATURI
30
-
CommentAt string
34
+
Rkey string
31
35
Issue int
32
36
CommentId int
33
37
Body string
34
38
Created *time.Time
39
+
Deleted *time.Time
40
+
Edited *time.Time
35
41
}
36
42
37
43
func NewIssue(tx *sql.Tx, issue *Issue) error {
···
116
122
issues i
117
123
left join
118
124
comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id
119
-
where
125
+
where
120
126
i.repo_at = ? and i.open = ?
121
127
group by
122
128
i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open
···
141
147
if err != nil {
142
148
return nil, err
143
149
}
144
-
issue.Created = &createdTime
150
+
issue.Created = createdTime
145
151
issue.Metadata = &metadata
146
152
147
153
issues = append(issues, issue)
···
154
160
return issues, nil
155
161
}
156
162
163
+
// timeframe here is directly passed into the sql query filter, and any
164
+
// timeframe in the past should be negative; e.g.: "-3 months"
165
+
func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) {
166
+
var issues []Issue
167
+
168
+
rows, err := e.Query(
169
+
`select
170
+
i.owner_did,
171
+
i.repo_at,
172
+
i.issue_id,
173
+
i.created,
174
+
i.title,
175
+
i.body,
176
+
i.open,
177
+
r.did,
178
+
r.name,
179
+
r.knot,
180
+
r.rkey,
181
+
r.created
182
+
from
183
+
issues i
184
+
join
185
+
repos r on i.repo_at = r.at_uri
186
+
where
187
+
i.owner_did = ? and i.created >= date ('now', ?)
188
+
order by
189
+
i.created desc`,
190
+
ownerDid, timeframe)
191
+
if err != nil {
192
+
return nil, err
193
+
}
194
+
defer rows.Close()
195
+
196
+
for rows.Next() {
197
+
var issue Issue
198
+
var issueCreatedAt, repoCreatedAt string
199
+
var repo Repo
200
+
err := rows.Scan(
201
+
&issue.OwnerDid,
202
+
&issue.RepoAt,
203
+
&issue.IssueId,
204
+
&issueCreatedAt,
205
+
&issue.Title,
206
+
&issue.Body,
207
+
&issue.Open,
208
+
&repo.Did,
209
+
&repo.Name,
210
+
&repo.Knot,
211
+
&repo.Rkey,
212
+
&repoCreatedAt,
213
+
)
214
+
if err != nil {
215
+
return nil, err
216
+
}
217
+
218
+
issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt)
219
+
if err != nil {
220
+
return nil, err
221
+
}
222
+
issue.Created = issueCreatedTime
223
+
224
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
225
+
if err != nil {
226
+
return nil, err
227
+
}
228
+
repo.Created = repoCreatedTime
229
+
230
+
issue.Metadata = &IssueMetadata{
231
+
Repo: &repo,
232
+
}
233
+
234
+
issues = append(issues, issue)
235
+
}
236
+
237
+
if err := rows.Err(); err != nil {
238
+
return nil, err
239
+
}
240
+
241
+
return issues, nil
242
+
}
243
+
157
244
func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) {
158
245
query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?`
159
246
row := e.QueryRow(query, repoAt, issueId)
···
169
256
if err != nil {
170
257
return nil, err
171
258
}
172
-
issue.Created = &createdTime
259
+
issue.Created = createdTime
173
260
174
261
return &issue, nil
175
262
}
···
189
276
if err != nil {
190
277
return nil, nil, err
191
278
}
192
-
issue.Created = &createdTime
279
+
issue.Created = createdTime
193
280
194
281
comments, err := GetComments(e, repoAt, issueId)
195
282
if err != nil {
···
199
286
return &issue, comments, nil
200
287
}
201
288
202
-
func NewComment(e Execer, comment *Comment) error {
203
-
query := `insert into comments (owner_did, repo_at, comment_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
289
+
func NewIssueComment(e Execer, comment *Comment) error {
290
+
query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)`
204
291
_, err := e.Exec(
205
292
query,
206
293
comment.OwnerDid,
207
294
comment.RepoAt,
208
-
comment.CommentAt,
295
+
comment.Rkey,
209
296
comment.Issue,
210
297
comment.CommentId,
211
298
comment.Body,
···
216
303
func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) {
217
304
var comments []Comment
218
305
219
-
rows, err := e.Query(`select owner_did, issue_id, comment_id, comment_at, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId)
306
+
rows, err := e.Query(`
307
+
select
308
+
owner_did,
309
+
issue_id,
310
+
comment_id,
311
+
rkey,
312
+
body,
313
+
created,
314
+
edited,
315
+
deleted
316
+
from
317
+
comments
318
+
where
319
+
repo_at = ? and issue_id = ?
320
+
order by
321
+
created asc`,
322
+
repoAt,
323
+
issueId,
324
+
)
220
325
if err == sql.ErrNoRows {
221
326
return []Comment{}, nil
222
327
}
···
228
333
for rows.Next() {
229
334
var comment Comment
230
335
var createdAt string
231
-
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt)
336
+
var deletedAt, editedAt, rkey sql.NullString
337
+
err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt)
232
338
if err != nil {
233
339
return nil, err
234
340
}
···
239
345
}
240
346
comment.Created = &createdAtTime
241
347
348
+
if deletedAt.Valid {
349
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
350
+
if err != nil {
351
+
return nil, err
352
+
}
353
+
comment.Deleted = &deletedTime
354
+
}
355
+
356
+
if editedAt.Valid {
357
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
358
+
if err != nil {
359
+
return nil, err
360
+
}
361
+
comment.Edited = &editedTime
362
+
}
363
+
364
+
if rkey.Valid {
365
+
comment.Rkey = rkey.String
366
+
}
367
+
242
368
comments = append(comments, comment)
243
369
}
244
370
···
247
373
}
248
374
249
375
return comments, nil
376
+
}
377
+
378
+
func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) {
379
+
query := `
380
+
select
381
+
owner_did, body, rkey, created, deleted, edited
382
+
from
383
+
comments where repo_at = ? and issue_id = ? and comment_id = ?
384
+
`
385
+
row := e.QueryRow(query, repoAt, issueId, commentId)
386
+
387
+
var comment Comment
388
+
var createdAt string
389
+
var deletedAt, editedAt, rkey sql.NullString
390
+
err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt)
391
+
if err != nil {
392
+
return nil, err
393
+
}
394
+
395
+
createdTime, err := time.Parse(time.RFC3339, createdAt)
396
+
if err != nil {
397
+
return nil, err
398
+
}
399
+
comment.Created = &createdTime
400
+
401
+
if deletedAt.Valid {
402
+
deletedTime, err := time.Parse(time.RFC3339, deletedAt.String)
403
+
if err != nil {
404
+
return nil, err
405
+
}
406
+
comment.Deleted = &deletedTime
407
+
}
408
+
409
+
if editedAt.Valid {
410
+
editedTime, err := time.Parse(time.RFC3339, editedAt.String)
411
+
if err != nil {
412
+
return nil, err
413
+
}
414
+
comment.Edited = &editedTime
415
+
}
416
+
417
+
if rkey.Valid {
418
+
comment.Rkey = rkey.String
419
+
}
420
+
421
+
comment.RepoAt = repoAt
422
+
comment.Issue = issueId
423
+
comment.CommentId = commentId
424
+
425
+
return &comment, nil
426
+
}
427
+
428
+
func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error {
429
+
_, err := e.Exec(
430
+
`
431
+
update comments
432
+
set body = ?,
433
+
edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
434
+
where repo_at = ? and issue_id = ? and comment_id = ?
435
+
`, newBody, repoAt, issueId, commentId)
436
+
return err
437
+
}
438
+
439
+
func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error {
440
+
_, err := e.Exec(
441
+
`
442
+
update comments
443
+
set body = "",
444
+
deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
445
+
where repo_at = ? and issue_id = ? and comment_id = ?
446
+
`, repoAt, issueId, commentId)
447
+
return err
250
448
}
251
449
252
450
func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+6
-10
appview/db/jetstream.go
+6
-10
appview/db/jetstream.go
···
5
5
}
6
6
7
7
func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error {
8
-
_, err := db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
8
+
_, err := db.Exec(`
9
+
insert into _jetstream (id, last_time_us)
10
+
values (1, ?)
11
+
on conflict(id) do update set last_time_us = excluded.last_time_us
12
+
`, lastTimeUs)
9
13
return err
10
14
}
11
15
12
-
func (db DbWrapper) UpdateLastTimeUs(lastTimeUs int64) error {
13
-
_, err := db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
14
-
if err != nil {
15
-
return err
16
-
}
17
-
return nil
18
-
}
19
-
20
16
func (db DbWrapper) GetLastTimeUs() (int64, error) {
21
17
var lastTimeUs int64
22
-
row := db.QueryRow(`select last_time_us from _jetstream`)
18
+
row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
23
19
err := row.Scan(&lastTimeUs)
24
20
return lastTimeUs, err
25
21
}
+164
appview/db/profile.go
+164
appview/db/profile.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"time"
6
+
)
7
+
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
82
+
}
83
+
84
+
const TimeframeMonths = 3
85
+
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)
94
+
if err != nil {
95
+
return nil, fmt.Errorf("error getting pulls by owner did: %w", err)
96
+
}
97
+
98
+
// group pulls by month
99
+
for _, pull := range pulls {
100
+
pullMonth := pull.Created.Month()
101
+
102
+
if currentMonth-pullMonth > TimeframeMonths {
103
+
// shouldn't happen; but times are weird
104
+
continue
105
+
}
106
+
107
+
idx := currentMonth - pullMonth
108
+
items := &timeline.ByMonth[idx].PullEvents.Items
109
+
110
+
*items = append(*items, &pull)
111
+
}
112
+
113
+
issues, err := GetIssuesByOwnerDid(e, forDid, timeframe)
114
+
if err != nil {
115
+
return nil, fmt.Errorf("error getting issues by owner did: %w", err)
116
+
}
117
+
118
+
for _, issue := range issues {
119
+
issueMonth := issue.Created.Month()
120
+
121
+
if currentMonth-issueMonth > TimeframeMonths {
122
+
// shouldn't happen; but times are weird
123
+
continue
124
+
}
125
+
126
+
idx := currentMonth - issueMonth
127
+
items := &timeline.ByMonth[idx].IssueEvents.Items
128
+
129
+
*items = append(*items, &issue)
130
+
}
131
+
132
+
repos, err := GetAllReposByDid(e, forDid)
133
+
if err != nil {
134
+
return nil, fmt.Errorf("error getting all repos by did: %w", err)
135
+
}
136
+
137
+
for _, repo := range repos {
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
+
}
153
+
154
+
idx := currentMonth - repoMonth
155
+
156
+
items := &timeline.ByMonth[idx].RepoEvents
157
+
*items = append(*items, RepoEvent{
158
+
Repo: &repo,
159
+
Source: sourceRepo,
160
+
})
161
+
}
162
+
163
+
return &timeline, nil
164
+
}
+193
-15
appview/db/pulls.go
+193
-15
appview/db/pulls.go
···
62
62
Submissions []*PullSubmission
63
63
64
64
// meta
65
-
Created time.Time
65
+
Created time.Time
66
+
PullSource *PullSource
67
+
68
+
// optionally, populate this when querying for reverse mappings
69
+
Repo *Repo
70
+
}
71
+
72
+
type PullSource struct {
73
+
Branch string
74
+
RepoAt *syntax.ATURI
75
+
76
+
// optionally populate this for reverse mappings
77
+
Repo *Repo
66
78
}
67
79
68
80
type PullSubmission struct {
···
77
89
RoundNumber int
78
90
Patch string
79
91
Comments []PullComment
92
+
SourceRev string // include the rev that was used to create this submission: only for branch PRs
80
93
81
94
// meta
82
95
Created time.Time
···
109
122
return len(p.Submissions) - 1
110
123
}
111
124
125
+
func (p *Pull) IsPatchBased() bool {
126
+
return p.PullSource == nil
127
+
}
128
+
129
+
func (p *Pull) IsBranchBased() bool {
130
+
if p.PullSource != nil {
131
+
if p.PullSource.RepoAt != nil {
132
+
return p.PullSource.RepoAt == &p.RepoAt
133
+
} else {
134
+
// no repo specified
135
+
return true
136
+
}
137
+
}
138
+
return false
139
+
}
140
+
141
+
func (p *Pull) IsForkBased() bool {
142
+
if p.PullSource != nil {
143
+
if p.PullSource.RepoAt != nil {
144
+
// make sure repos are different
145
+
return p.PullSource.RepoAt != &p.RepoAt
146
+
}
147
+
}
148
+
return false
149
+
}
150
+
112
151
func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff {
113
152
patch := s.Patch
114
153
···
175
214
pull.PullId = nextId
176
215
pull.State = PullOpen
177
216
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)
217
+
var sourceBranch, sourceRepoAt *string
218
+
if pull.PullSource != nil {
219
+
sourceBranch = &pull.PullSource.Branch
220
+
if pull.PullSource.RepoAt != nil {
221
+
x := pull.PullSource.RepoAt.String()
222
+
sourceRepoAt = &x
223
+
}
224
+
}
225
+
226
+
_, err = tx.Exec(
227
+
`
228
+
insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at)
229
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
230
+
pull.RepoAt,
231
+
pull.OwnerDid,
232
+
pull.PullId,
233
+
pull.Title,
234
+
pull.TargetBranch,
235
+
pull.Body,
236
+
pull.Rkey,
237
+
pull.State,
238
+
sourceBranch,
239
+
sourceRepoAt,
240
+
)
182
241
if err != nil {
183
242
return err
184
243
}
185
244
186
245
_, 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)
246
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
247
+
values (?, ?, ?, ?, ?)
248
+
`, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev)
190
249
if err != nil {
191
250
return err
192
251
}
···
228
287
target_branch,
229
288
pull_at,
230
289
body,
231
-
rkey
290
+
rkey,
291
+
source_branch,
292
+
source_repo_at
232
293
from
233
294
pulls
234
295
where
···
243
304
for rows.Next() {
244
305
var pull Pull
245
306
var createdAt string
307
+
var sourceBranch, sourceRepoAt sql.NullString
246
308
err := rows.Scan(
247
309
&pull.OwnerDid,
248
310
&pull.PullId,
···
253
315
&pull.PullAt,
254
316
&pull.Body,
255
317
&pull.Rkey,
318
+
&sourceBranch,
319
+
&sourceRepoAt,
256
320
)
257
321
if err != nil {
258
322
return nil, err
···
264
328
}
265
329
pull.Created = createdTime
266
330
331
+
if sourceBranch.Valid {
332
+
pull.PullSource = &PullSource{
333
+
Branch: sourceBranch.String,
334
+
}
335
+
if sourceRepoAt.Valid {
336
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
337
+
if err != nil {
338
+
return nil, err
339
+
}
340
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
341
+
}
342
+
}
343
+
267
344
pulls = append(pulls, pull)
268
345
}
269
346
···
286
363
pull_at,
287
364
repo_at,
288
365
body,
289
-
rkey
366
+
rkey,
367
+
source_branch,
368
+
source_repo_at
290
369
from
291
370
pulls
292
371
where
···
296
375
297
376
var pull Pull
298
377
var createdAt string
378
+
var sourceBranch, sourceRepoAt sql.NullString
299
379
err := row.Scan(
300
380
&pull.OwnerDid,
301
381
&pull.PullId,
···
307
387
&pull.RepoAt,
308
388
&pull.Body,
309
389
&pull.Rkey,
390
+
&sourceBranch,
391
+
&sourceRepoAt,
310
392
)
311
393
if err != nil {
312
394
return nil, err
···
318
400
}
319
401
pull.Created = createdTime
320
402
403
+
// populate source
404
+
if sourceBranch.Valid {
405
+
pull.PullSource = &PullSource{
406
+
Branch: sourceBranch.String,
407
+
}
408
+
if sourceRepoAt.Valid {
409
+
sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String)
410
+
if err != nil {
411
+
return nil, err
412
+
}
413
+
pull.PullSource.RepoAt = &sourceRepoAtParsed
414
+
}
415
+
}
416
+
321
417
submissionsQuery := `
322
418
select
323
-
id, pull_id, repo_at, round_number, patch, created
419
+
id, pull_id, repo_at, round_number, patch, created, source_rev
324
420
from
325
421
pull_submissions
326
422
where
···
337
433
for submissionsRows.Next() {
338
434
var submission PullSubmission
339
435
var submissionCreatedStr string
436
+
var submissionSourceRev sql.NullString
340
437
err := submissionsRows.Scan(
341
438
&submission.ID,
342
439
&submission.PullId,
···
344
441
&submission.RoundNumber,
345
442
&submission.Patch,
346
443
&submissionCreatedStr,
444
+
&submissionSourceRev,
347
445
)
348
446
if err != nil {
349
447
return nil, err
···
354
452
return nil, err
355
453
}
356
454
submission.Created = submissionCreatedTime
455
+
456
+
if submissionSourceRev.Valid {
457
+
submission.SourceRev = submissionSourceRev.String
458
+
}
357
459
358
460
submissionsMap[submission.ID] = &submission
359
461
}
···
433
535
return &pull, nil
434
536
}
435
537
538
+
// timeframe here is directly passed into the sql query filter, and any
539
+
// timeframe in the past should be negative; e.g.: "-3 months"
540
+
func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) {
541
+
var pulls []Pull
542
+
543
+
rows, err := e.Query(`
544
+
select
545
+
p.owner_did,
546
+
p.repo_at,
547
+
p.pull_id,
548
+
p.created,
549
+
p.title,
550
+
p.state,
551
+
r.did,
552
+
r.name,
553
+
r.knot,
554
+
r.rkey,
555
+
r.created
556
+
from
557
+
pulls p
558
+
join
559
+
repos r on p.repo_at = r.at_uri
560
+
where
561
+
p.owner_did = ? and p.created >= date ('now', ?)
562
+
order by
563
+
p.created desc`, did, timeframe)
564
+
if err != nil {
565
+
return nil, err
566
+
}
567
+
defer rows.Close()
568
+
569
+
for rows.Next() {
570
+
var pull Pull
571
+
var repo Repo
572
+
var pullCreatedAt, repoCreatedAt string
573
+
err := rows.Scan(
574
+
&pull.OwnerDid,
575
+
&pull.RepoAt,
576
+
&pull.PullId,
577
+
&pullCreatedAt,
578
+
&pull.Title,
579
+
&pull.State,
580
+
&repo.Did,
581
+
&repo.Name,
582
+
&repo.Knot,
583
+
&repo.Rkey,
584
+
&repoCreatedAt,
585
+
)
586
+
if err != nil {
587
+
return nil, err
588
+
}
589
+
590
+
pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt)
591
+
if err != nil {
592
+
return nil, err
593
+
}
594
+
pull.Created = pullCreatedTime
595
+
596
+
repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt)
597
+
if err != nil {
598
+
return nil, err
599
+
}
600
+
repo.Created = repoCreatedTime
601
+
602
+
pull.Repo = &repo
603
+
604
+
pulls = append(pulls, pull)
605
+
}
606
+
607
+
if err := rows.Err(); err != nil {
608
+
return nil, err
609
+
}
610
+
611
+
return pulls, nil
612
+
}
613
+
436
614
func NewPullComment(e Execer, comment *PullComment) (int64, error) {
437
615
query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)`
438
616
res, err := e.Exec(
···
476
654
return err
477
655
}
478
656
479
-
func ResubmitPull(e Execer, pull *Pull, newPatch string) error {
657
+
func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error {
480
658
newRoundNumber := len(pull.Submissions)
481
659
_, err := e.Exec(`
482
-
insert into pull_submissions (pull_id, repo_at, round_number, patch)
483
-
values (?, ?, ?, ?)
484
-
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch)
660
+
insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev)
661
+
values (?, ?, ?, ?, ?)
662
+
`, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev)
485
663
486
664
return err
487
665
}
+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
+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
+
}
+10
-1
appview/pages/funcmap.go
+10
-1
appview/pages/funcmap.go
···
13
13
"time"
14
14
15
15
"github.com/dustin/go-humanize"
16
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
16
17
)
17
18
18
19
func funcMap() template.FuncMap {
···
30
31
return strings.Split(s, sep)
31
32
},
32
33
"add": func(a, b int) int {
34
+
return a + b
35
+
},
36
+
// the absolute state of go templates
37
+
"add64": func(a, b int64) int64 {
33
38
return a + b
34
39
},
35
40
"sub": func(a, b int) int {
···
68
73
return s
69
74
},
70
75
"timeFmt": humanize.Time,
76
+
"longTimeFmt": func(t time.Time) string {
77
+
return t.Format("2006-01-02 * 3:04 PM")
78
+
},
71
79
"shortTimeFmt": func(t time.Time) string {
72
80
return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{
73
81
{time.Second, "now", time.Second},
···
134
142
return v.Slice(start, end).Interface()
135
143
},
136
144
"markdown": func(text string) template.HTML {
137
-
return template.HTML(renderMarkdown(text))
145
+
return template.HTML(markup.RenderMarkdown(text))
138
146
},
139
147
"isNil": func(t any) bool {
140
148
// returns false for other "zero" values
···
165
173
}
166
174
return template.HTML(data)
167
175
},
176
+
"cssContentHash": CssContentHash,
168
177
}
169
178
}
170
179
-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
+
}
+246
-70
appview/pages/pages.go
+246
-70
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"
···
22
24
"github.com/microcosm-cc/bluemonday"
23
25
"tangled.sh/tangled.sh/core/appview/auth"
24
26
"tangled.sh/tangled.sh/core/appview/db"
27
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
28
"tangled.sh/tangled.sh/core/appview/state/userutil"
26
29
"tangled.sh/tangled.sh/core/types"
27
30
)
···
36
39
func NewPages() *Pages {
37
40
templates := make(map[string]*template.Template)
38
41
39
-
// Walk through embedded templates directory and parse all .html files
42
+
var fragmentPaths []string
43
+
// First, collect all fragment paths
40
44
err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
41
45
if err != nil {
42
46
return err
43
47
}
44
48
45
-
if !d.IsDir() && strings.HasSuffix(path, ".html") {
46
-
name := strings.TrimPrefix(path, "templates/")
47
-
name = strings.TrimSuffix(name, ".html")
49
+
if d.IsDir() {
50
+
return nil
51
+
}
48
52
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
-
}
53
+
if !strings.HasSuffix(path, ".html") {
54
+
return nil
55
+
}
56
+
57
+
if !strings.Contains(path, "fragments/") {
58
+
return nil
59
+
}
60
+
61
+
name := strings.TrimPrefix(path, "templates/")
62
+
name = strings.TrimSuffix(name, ".html")
63
+
64
+
tmpl, err := template.New(name).
65
+
Funcs(funcMap()).
66
+
ParseFS(Files, path)
67
+
if err != nil {
68
+
log.Fatalf("setting up fragment: %v", err)
69
+
}
70
+
71
+
templates[name] = tmpl
72
+
fragmentPaths = append(fragmentPaths, path)
73
+
log.Printf("loaded fragment: %s", name)
74
+
return nil
75
+
})
76
+
if err != nil {
77
+
log.Fatalf("walking template dir for fragments: %v", err)
78
+
}
57
79
58
-
templates[name] = tmpl
59
-
log.Printf("loaded fragment: %s", name)
60
-
}
80
+
// Then walk through and setup the rest of the templates
81
+
err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error {
82
+
if err != nil {
83
+
return err
84
+
}
61
85
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
-
}
86
+
if d.IsDir() {
87
+
return nil
88
+
}
72
89
73
-
templates[name] = tmpl
74
-
log.Printf("loaded template: %s", name)
75
-
}
90
+
if !strings.HasSuffix(path, "html") {
91
+
return nil
92
+
}
76
93
94
+
// Skip fragments as they've already been loaded
95
+
if strings.Contains(path, "fragments/") {
77
96
return nil
78
97
}
98
+
99
+
// Skip layouts
100
+
if strings.Contains(path, "layouts/") {
101
+
return nil
102
+
}
103
+
104
+
name := strings.TrimPrefix(path, "templates/")
105
+
name = strings.TrimSuffix(name, ".html")
106
+
107
+
// Add the page template on top of the base
108
+
allPaths := []string{}
109
+
allPaths = append(allPaths, "templates/layouts/*.html")
110
+
allPaths = append(allPaths, fragmentPaths...)
111
+
allPaths = append(allPaths, path)
112
+
tmpl, err := template.New(name).
113
+
Funcs(funcMap()).
114
+
ParseFS(Files, allPaths...)
115
+
if err != nil {
116
+
return fmt.Errorf("setting up template: %w", err)
117
+
}
118
+
119
+
templates[name] = tmpl
120
+
log.Printf("loaded template: %s", name)
79
121
return nil
80
122
})
81
123
if err != nil {
···
139
181
140
182
type KnotParams struct {
141
183
LoggedInUser *auth.User
184
+
DidHandleMap map[string]string
142
185
Registration *db.Registration
143
186
Members []string
144
187
IsOwner bool
···
157
200
return p.execute("repo/new", w, params)
158
201
}
159
202
203
+
type ForkRepoParams struct {
204
+
LoggedInUser *auth.User
205
+
Knots []string
206
+
RepoInfo RepoInfo
207
+
}
208
+
209
+
func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
210
+
return p.execute("repo/fork", w, params)
211
+
}
212
+
160
213
type ProfilePageParams struct {
161
214
LoggedInUser *auth.User
162
215
UserDid string
···
165
218
CollaboratingRepos []db.Repo
166
219
ProfileStats ProfileStats
167
220
FollowStatus db.FollowStatus
168
-
DidHandleMap map[string]string
169
221
AvatarUri string
222
+
ProfileTimeline *db.ProfileTimeline
223
+
224
+
DidHandleMap map[string]string
170
225
}
171
226
172
227
type ProfileStats struct {
···
184
239
}
185
240
186
241
func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
187
-
return p.executePlain("fragments/follow", w, params)
242
+
return p.executePlain("user/fragments/follow", w, params)
188
243
}
189
244
190
-
type StarFragmentParams struct {
245
+
type RepoActionsFragmentParams struct {
191
246
IsStarred bool
192
247
RepoAt syntax.ATURI
193
248
Stats db.RepoStats
194
249
}
195
250
196
-
func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error {
197
-
return p.executePlain("fragments/star", w, params)
251
+
func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error {
252
+
return p.executePlain("repo/fragments/repoActions", w, params)
198
253
}
199
254
200
255
type RepoDescriptionParams struct {
···
202
257
}
203
258
204
259
func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
205
-
return p.executePlain("fragments/editRepoDescription", w, params)
260
+
return p.executePlain("repo/fragments/editRepoDescription", w, params)
206
261
}
207
262
208
263
func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error {
209
-
return p.executePlain("fragments/repoDescription", w, params)
264
+
return p.executePlain("repo/fragments/repoDescription", w, params)
210
265
}
211
266
212
267
type RepoInfo struct {
213
-
Name string
214
-
OwnerDid string
215
-
OwnerHandle string
216
-
Description string
217
-
Knot string
218
-
RepoAt syntax.ATURI
219
-
IsStarred bool
220
-
Stats db.RepoStats
221
-
Roles RolesInRepo
268
+
Name string
269
+
OwnerDid string
270
+
OwnerHandle string
271
+
Description string
272
+
Knot string
273
+
RepoAt syntax.ATURI
274
+
IsStarred bool
275
+
Stats db.RepoStats
276
+
Roles RolesInRepo
277
+
Source *db.Repo
278
+
SourceHandle string
279
+
DisableFork bool
222
280
}
223
281
224
282
type RolesInRepo struct {
···
227
285
228
286
func (r RolesInRepo) SettingsAllowed() bool {
229
287
return slices.Contains(r.Roles, "repo:settings")
288
+
}
289
+
290
+
func (r RolesInRepo) CollaboratorInviteAllowed() bool {
291
+
return slices.Contains(r.Roles, "repo:invite")
292
+
}
293
+
294
+
func (r RolesInRepo) RepoDeleteAllowed() bool {
295
+
return slices.Contains(r.Roles, "repo:delete")
230
296
}
231
297
232
298
func (r RolesInRepo) IsOwner() bool {
···
267
333
268
334
func (r RepoInfo) GetTabs() [][]string {
269
335
tabs := [][]string{
270
-
{"overview", "/"},
271
-
{"issues", "/issues"},
272
-
{"pulls", "/pulls"},
336
+
{"overview", "/", "square-chart-gantt"},
337
+
{"issues", "/issues", "circle-dot"},
338
+
{"pulls", "/pulls", "git-pull-request"},
273
339
}
274
340
275
341
if r.Roles.SettingsAllowed() {
276
-
tabs = append(tabs, []string{"settings", "/settings"})
342
+
tabs = append(tabs, []string{"settings", "/settings", "cog"})
277
343
}
278
344
279
345
return tabs
···
324
390
ext := filepath.Ext(params.ReadmeFileName)
325
391
switch ext {
326
392
case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
327
-
htmlString = renderMarkdown(params.Readme)
393
+
htmlString = markup.RenderMarkdown(params.Readme)
328
394
params.Raw = false
329
395
params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString))
330
396
default:
···
420
486
}
421
487
422
488
type RepoBlobParams struct {
423
-
LoggedInUser *auth.User
424
-
RepoInfo RepoInfo
425
-
Active string
426
-
BreadCrumbs [][]string
489
+
LoggedInUser *auth.User
490
+
RepoInfo RepoInfo
491
+
Active string
492
+
BreadCrumbs [][]string
493
+
ShowRendered bool
494
+
RenderToggle bool
495
+
RenderedContents template.HTML
427
496
types.RepoBlobResponse
428
497
}
429
498
···
433
502
b.Add(chroma.LiteralString, "noitalic")
434
503
style, _ = b.Build()
435
504
505
+
if params.ShowRendered {
506
+
switch markup.GetFormat(params.Path) {
507
+
case markup.FormatMarkdown:
508
+
params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents))
509
+
}
510
+
}
511
+
436
512
if params.Lines < 5000 {
437
513
c := params.Contents
438
514
formatter := chromahtml.New(
439
-
chromahtml.InlineCode(true),
515
+
chromahtml.InlineCode(false),
440
516
chromahtml.WithLineNumbers(true),
441
517
chromahtml.WithLinkableLineNumbers(true, "L"),
442
518
chromahtml.Standalone(false),
···
476
552
RepoInfo RepoInfo
477
553
Collaborators []Collaborator
478
554
Active string
555
+
Branches []string
556
+
DefaultBranch string
479
557
// TODO: use repoinfo.roles
480
558
IsCollaboratorInviteAllowed bool
481
559
}
···
533
611
return p.executeRepo("repo/issues/new", w, params)
534
612
}
535
613
614
+
type EditIssueCommentParams struct {
615
+
LoggedInUser *auth.User
616
+
RepoInfo RepoInfo
617
+
Issue *db.Issue
618
+
Comment *db.Comment
619
+
}
620
+
621
+
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
622
+
return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
623
+
}
624
+
625
+
type SingleIssueCommentParams struct {
626
+
LoggedInUser *auth.User
627
+
DidHandleMap map[string]string
628
+
RepoInfo RepoInfo
629
+
Issue *db.Issue
630
+
Comment *db.Comment
631
+
}
632
+
633
+
func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error {
634
+
return p.executePlain("repo/issues/fragments/issueComment", w, params)
635
+
}
636
+
536
637
type RepoNewPullParams struct {
537
638
LoggedInUser *auth.User
538
639
RepoInfo RepoInfo
···
559
660
return p.executeRepo("repo/pulls/pulls", w, params)
560
661
}
561
662
562
-
type RepoSinglePullParams struct {
563
-
LoggedInUser *auth.User
564
-
RepoInfo RepoInfo
565
-
Active string
566
-
DidHandleMap map[string]string
663
+
type ResubmitResult uint64
664
+
665
+
const (
666
+
ShouldResubmit ResubmitResult = iota
667
+
ShouldNotResubmit
668
+
Unknown
669
+
)
567
670
568
-
Pull db.Pull
569
-
MergeCheck types.MergeCheckResponse
671
+
func (r ResubmitResult) Yes() bool {
672
+
return r == ShouldResubmit
673
+
}
674
+
func (r ResubmitResult) No() bool {
675
+
return r == ShouldNotResubmit
676
+
}
677
+
func (r ResubmitResult) Unknown() bool {
678
+
return r == Unknown
679
+
}
680
+
681
+
type RepoSinglePullParams struct {
682
+
LoggedInUser *auth.User
683
+
RepoInfo RepoInfo
684
+
Active string
685
+
DidHandleMap map[string]string
686
+
Pull *db.Pull
687
+
PullSourceRepo *db.Repo
688
+
MergeCheck types.MergeCheckResponse
689
+
ResubmitCheck ResubmitResult
570
690
}
571
691
572
692
func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
···
589
709
return p.execute("repo/pulls/patch", w, params)
590
710
}
591
711
712
+
type PullPatchUploadParams struct {
713
+
RepoInfo RepoInfo
714
+
}
715
+
716
+
func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
717
+
return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
718
+
}
719
+
720
+
type PullCompareBranchesParams struct {
721
+
RepoInfo RepoInfo
722
+
Branches []types.Branch
723
+
}
724
+
725
+
func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
726
+
return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
727
+
}
728
+
729
+
type PullCompareForkParams struct {
730
+
RepoInfo RepoInfo
731
+
Forks []db.Repo
732
+
}
733
+
734
+
func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
735
+
return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
736
+
}
737
+
738
+
type PullCompareForkBranchesParams struct {
739
+
RepoInfo RepoInfo
740
+
SourceBranches []types.Branch
741
+
TargetBranches []types.Branch
742
+
}
743
+
744
+
func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
745
+
return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
746
+
}
747
+
592
748
type PullResubmitParams struct {
593
749
LoggedInUser *auth.User
594
750
RepoInfo RepoInfo
···
597
753
}
598
754
599
755
func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
600
-
return p.executePlain("fragments/pullResubmit", w, params)
756
+
return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
601
757
}
602
758
603
759
type PullActionsParams struct {
604
-
LoggedInUser *auth.User
605
-
RepoInfo RepoInfo
606
-
Pull *db.Pull
607
-
RoundNumber int
608
-
MergeCheck types.MergeCheckResponse
760
+
LoggedInUser *auth.User
761
+
RepoInfo RepoInfo
762
+
Pull *db.Pull
763
+
RoundNumber int
764
+
MergeCheck types.MergeCheckResponse
765
+
ResubmitCheck ResubmitResult
609
766
}
610
767
611
768
func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
612
-
return p.executePlain("fragments/pullActions", w, params)
769
+
return p.executePlain("repo/pulls/fragments/pullActions", w, params)
613
770
}
614
771
615
772
type PullNewCommentParams struct {
···
620
777
}
621
778
622
779
func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
623
-
return p.executePlain("fragments/pullNewComment", w, params)
780
+
return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
624
781
}
625
782
626
783
func (p *Pages) Static() http.Handler {
···
634
791
635
792
func Cache(h http.Handler) http.Handler {
636
793
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
637
-
if strings.HasSuffix(r.URL.Path, ".css") {
794
+
path := strings.Split(r.URL.Path, "?")[0]
795
+
796
+
if strings.HasSuffix(path, ".css") {
638
797
// on day for css files
639
798
w.Header().Set("Cache-Control", "public, max-age=86400")
640
799
} else {
···
642
801
}
643
802
h.ServeHTTP(w, r)
644
803
})
804
+
}
805
+
806
+
func CssContentHash() string {
807
+
cssFile, err := Files.Open("static/tw.css")
808
+
if err != nil {
809
+
log.Printf("Error opening CSS file: %v", err)
810
+
return ""
811
+
}
812
+
defer cssFile.Close()
813
+
814
+
hasher := sha256.New()
815
+
if _, err := io.Copy(hasher, cssFile); err != nil {
816
+
log.Printf("Error hashing CSS file: %v", err)
817
+
return ""
818
+
}
819
+
820
+
return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
645
821
}
646
822
647
823
func (p *Pages) Error500(w io.Writer) error {
-112
appview/pages/templates/fragments/diff.html
-112
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 w-full mx-auto rounded bg-white 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 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 {{ $markerstyle }}">ADDED</span>
25
-
{{ else if .IsDelete }}
26
-
<span class="bg-red-100 text-red-700 {{ $markerstyle }}">DELETED</span>
27
-
{{ else if .IsCopy }}
28
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">COPIED</span>
29
-
{{ else if .IsRename }}
30
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">RENAMED</span>
31
-
{{ else }}
32
-
<span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">MODIFIED</span>
33
-
{{ end }}
34
-
35
-
{{ if .IsDelete }}
36
-
<a {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
37
-
{{ .Name.Old }}
38
-
</a>
39
-
{{ else if (or .IsCopy .IsRename) }}
40
-
<a {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
41
-
{{ .Name.Old }}
42
-
</a>
43
-
{{ i "arrow-right" "w-4 h-4" }}
44
-
<a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
45
-
{{ .Name.New }}
46
-
</a>
47
-
{{ else }}
48
-
<a {{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 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 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 p-4">
79
-
This is a binary file and will not be displayed.
80
-
</p>
81
-
{{ else }}
82
-
<pre class="overflow-auto">
83
-
{{- range .TextFragments -}}
84
-
<div class="bg-gray-100 text-gray-500 select-none">{{ .Header }}</div>
85
-
{{- range .Lines -}}
86
-
{{- if eq .Op.String "+" -}}
87
-
<div class="bg-green-100 text-green-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
88
-
{{- end -}}
89
-
90
-
{{- if eq .Op.String "-" -}}
91
-
<div class="bg-red-100 text-red-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
92
-
{{- end -}}
93
-
94
-
{{- if eq .Op.String " " -}}
95
-
<div class="bg-white text-gray-500 px"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div>
96
-
{{- end -}}
97
-
98
-
{{- end -}}
99
-
{{- end -}}
100
-
</pre>
101
-
{{- end -}}
102
-
{{ end }}
103
-
</div>
104
-
105
-
</details>
106
-
107
-
</div>
108
-
</div>
109
-
</section>
110
-
{{ end }}
111
-
{{ end }}
112
-
{{ end }}
-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 }}
-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"></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 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
5
-
<div class="text-sm text-gray-500">
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 border-amber-200 px-6 py-2">
5
-
6
-
<div class="flex items-center gap-2 text-amber-500">
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">
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 rounded border border-gray-200"
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
-
+92
-34
appview/pages/templates/knot.html
+92
-34
appview/pages/templates/knot.html
···
1
-
{{define "title"}}{{ .Registration.Domain }}{{end}}
1
+
{{ define "title" }}{{ .Registration.Domain }}{{ end }}
2
2
3
-
{{define "content"}}
4
-
<h1>{{.Registration.Domain}}</h1>
5
-
<p>
6
-
<code>
7
-
opened by: {{.Registration.ByDid}}
8
-
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
9
-
(you)
10
-
{{ end }}
11
-
</code><br>
12
-
<code>on: {{.Registration.Created}}</code><br>
13
-
{{ if .Registration.Registered }}
14
-
<code>registered on: {{.Registration.Registered}}</code>
15
-
{{ else }}
16
-
<code>pending registration</code>
17
-
<button class="btn my-2" hx-post="/knots/{{.Domain}}/init" hx-swap="none">initialize</button>
3
+
{{ define "content" }}
4
+
<div class="p-6">
5
+
<p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p>
6
+
</div>
7
+
8
+
<div class="flex flex-col">
9
+
{{ block "registration-info" . }} {{ end }}
10
+
{{ block "members" . }} {{ end }}
11
+
{{ block "add-member" . }} {{ end }}
12
+
</div>
13
+
{{ end }}
14
+
15
+
{{ define "registration-info" }}
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
+
<dt class="font-bold">opened by</dt>
19
+
<dd>
20
+
<span>
21
+
{{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span>
22
+
</span>
23
+
{{ if eq $.LoggedInUser.Did $.Registration.ByDid }}
24
+
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span>
18
25
{{ end }}
19
-
</p>
20
-
26
+
</dd>
27
+
28
+
<dt class="font-bold">opened</dt>
29
+
<dd>{{ .Registration.Created | timeFmt }}</dd>
30
+
21
31
{{ if .Registration.Registered }}
22
-
<h3> members </h3>
23
-
<ol>
24
-
{{ range $.Members }}
25
-
<li><a href="/{{.}}">{{.}}</a></li>
32
+
<dt class="font-bold">registered</dt>
33
+
<dd>{{ .Registration.Registered | timeFmt }}</dd>
26
34
{{ else }}
27
-
<p>no members</p>
35
+
<dt class="font-bold">status</dt>
36
+
<dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block">
37
+
Pending Registration
38
+
</dd>
28
39
{{ end }}
29
-
{{ end }}
30
-
</ol>
40
+
</dl>
41
+
42
+
{{ if not .Registration.Registered }}
43
+
<div class="mt-4">
44
+
<button
45
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
46
+
hx-post="/knots/{{.Domain}}/init"
47
+
hx-swap="none">
48
+
Initialize Registration
49
+
</button>
50
+
</div>
51
+
{{ end }}
52
+
</section>
53
+
{{ end }}
54
+
55
+
{{ define "members" }}
56
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2>
57
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
58
+
{{ if .Registration.Registered }}
59
+
<div id="member-list" class="flex flex-col gap-4">
60
+
{{ range $.Members }}
61
+
<div class="inline-flex items-center gap-4">
62
+
{{ i "user" "w-4 h-4 dark:text-gray-300" }}
63
+
<a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}}
64
+
<span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span>
65
+
</a>
66
+
</div>
67
+
{{ else }}
68
+
<p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p>
69
+
{{ end }}
70
+
</div>
71
+
{{ else }}
72
+
<p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p>
73
+
{{ end }}
74
+
</section>
75
+
{{ end }}
31
76
32
-
{{ if $.IsOwner }}
33
-
<h3>add member</h3>
34
-
<form hx-put="/knots/{{.Registration.Domain}}/member">
35
-
<label for="member">did or handle:</label>
36
-
<input type="text" id="member" name="member" required>
37
-
<button class="btn my-2" type="text">add member</button>
38
-
</form>
39
-
{{ end }}
40
-
{{end}}
77
+
{{ define "add-member" }}
78
+
{{ if $.IsOwner }}
79
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2>
80
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
81
+
<form
82
+
hx-put="/knots/{{.Registration.Domain}}/member"
83
+
class="max-w-2xl space-y-4">
84
+
<input
85
+
type="text"
86
+
id="member"
87
+
name="member"
88
+
placeholder="did or handle"
89
+
required
90
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
91
+
92
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button>
93
+
94
+
<div id="add-member-error" class="error dark:text-red-400"></div>
95
+
</form>
96
+
</section>
97
+
{{ end }}
98
+
{{ end }}
+79
-84
appview/pages/templates/knots.html
+79
-84
appview/pages/templates/knots.html
···
1
1
{{ define "title" }}knots{{ end }}
2
-
3
2
{{ define "content" }}
4
-
<h1>knots</h1>
3
+
<div class="p-6">
4
+
<p class="text-xl font-bold dark:text-white">Knots</p>
5
+
</div>
6
+
<div class="flex flex-col">
7
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2>
8
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
9
+
<p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p>
10
+
<form
11
+
hx-post="/knots/key"
12
+
class="max-w-2xl mb-8 space-y-4"
13
+
>
14
+
<input
15
+
type="text"
16
+
id="domain"
17
+
name="domain"
18
+
placeholder="knot.example.com"
19
+
required
20
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"
21
+
/>
22
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">
23
+
generate key
24
+
</button>
25
+
<div id="settings-knots-error" class="error dark:text-red-400"></div>
26
+
</form>
27
+
</section>
5
28
6
-
<section class="mb-12">
7
-
<h2 class="text-2xl mb-4">register a knot</h2>
8
-
<form hx-post="/knots/key" class="flex gap-4 items-end">
9
-
<div>
10
-
<label for="domain"
11
-
>Generate a key to start your knot with.</label
12
-
>
13
-
<input
14
-
type="text"
15
-
id="domain"
16
-
name="domain"
17
-
placeholder="knot.example.com"
18
-
required
19
-
/>
29
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2>
30
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
31
+
<div id="knots-list" class="flex flex-col gap-6 mb-8">
32
+
{{ range .Registrations }}
33
+
{{ if .Registered }}
34
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
35
+
<div class="flex flex-col gap-1">
36
+
<div class="inline-flex items-center gap-4">
37
+
{{ i "git-branch" "w-3 h-3 dark:text-gray-300" }}
38
+
<a href="/knots/{{ .Domain }}">
39
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
40
+
</a>
41
+
</div>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p>
43
+
<p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p>
20
44
</div>
21
-
<button class="btn" type="submit">generate key</button>
22
-
</form>
45
+
</div>
46
+
{{ end }}
47
+
{{ else }}
48
+
<p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p>
49
+
{{ end }}
50
+
</div>
23
51
</section>
24
52
25
-
<section class="mb-12">
26
-
<h3 class="text-xl font-semibold mb-4">my knots</h3>
27
-
<p>This is a list of knots</p>
28
-
<ul id="my-knots" class="space-y-6">
29
-
{{ range .Registrations }}
30
-
{{ if .Registered }}
31
-
<li class="border rounded p-4 flex flex-col gap-2">
32
-
<div>
33
-
<a href="/knots/{{ .Domain }}" class="font-semibold"
34
-
>{{ .Domain }}</a
35
-
>
36
-
</div>
37
-
<div class="text-gray-600">
38
-
Owned by
39
-
{{ .ByDid }}
40
-
</div>
41
-
<div class="text-gray-600">
42
-
Registered on
43
-
{{ .Registered }}
44
-
</div>
45
-
</li>
46
-
{{ end }}
47
-
{{ else }}
48
-
<p class="text-gray-600">you don't have any knots yet</p>
49
-
{{ end }}
50
-
</ul>
53
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2>
54
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
55
+
<div id="pending-knots-list" class="flex flex-col gap-6 mb-8">
56
+
{{ range .Registrations }}
57
+
{{ if not .Registered }}
58
+
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
59
+
<div class="flex flex-col gap-1">
60
+
<div class="inline-flex items-center gap-4">
61
+
<p class="font-bold dark:text-white">{{ .Domain }}</p>
62
+
<div class="inline-flex items-center gap-1">
63
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">
64
+
pending
65
+
</span>
66
+
</div>
67
+
</div>
68
+
<p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p>
69
+
<p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p>
70
+
</div>
71
+
<div class="flex gap-2 items-center">
72
+
<button
73
+
class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 gap-2"
74
+
hx-post="/knots/{{ .Domain }}/init">
75
+
{{ i "square-play" "w-5 h-5" }}
76
+
<span class="hidden md:inline">initialize</span>
77
+
</button>
78
+
</div>
79
+
</div>
80
+
{{ end }}
81
+
{{ else }}
82
+
<p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p>
83
+
{{ end }}
84
+
</div>
51
85
</section>
52
-
53
-
<section>
54
-
<h3 class="text-xl font-semibold mb-4">pending registrations</h3>
55
-
<ul id="pending-registrations" class="space-y-6">
56
-
{{ range .Registrations }}
57
-
{{ if not .Registered }}
58
-
<li class="border rounded p-4 flex flex-col gap-2">
59
-
<div>
60
-
<a
61
-
href="/knots/{{ .Domain }}"
62
-
class="text-blue-600 hover:underline"
63
-
>{{ .Domain }}</a
64
-
>
65
-
</div>
66
-
<div class="text-gray-600">
67
-
Opened by
68
-
{{ .ByDid }}
69
-
</div>
70
-
<div class="text-gray-600">
71
-
Created on
72
-
{{ .Created }}
73
-
</div>
74
-
<div class="flex items-center gap-4 mt-2">
75
-
<span class="text-amber-600"
76
-
>pending registration</span
77
-
>
78
-
<button
79
-
class="btn"
80
-
hx-post="/knots/{{ .Domain }}/init"
81
-
>
82
-
initialize
83
-
</button>
84
-
</div>
85
-
</li>
86
-
{{ end }}
87
-
{{ else }}
88
-
<p class="text-gray-600">no registrations yet</p>
89
-
{{ end }}
90
-
</ul>
91
-
</section>
86
+
</div>
92
87
{{ end }}
+5
-6
appview/pages/templates/layouts/base.html
+5
-6
appview/pages/templates/layouts/base.html
···
1
1
{{ define "layouts/base" }}
2
2
<!doctype html>
3
-
<html lang="en">
3
+
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
6
<meta
···
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
-
<body class="bg-slate-100">
17
-
<div class="container mx-auto px-1 pt-4 min-h-screen flex flex-col">
18
-
<header style="z-index: 5">
15
+
<body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200">
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 }}
+34
-19
appview/pages/templates/layouts/repobase.html
+34
-19
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">
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">
18
-
{{ $activeTabStyles := "-mb-px bg-white" }}
29
+
{{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }}
19
30
{{ $tabs := .RepoInfo.GetTabs }}
20
31
{{ $tabmeta := .RepoInfo.TabMetadata }}
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 }}"
···
28
40
hx-boost="true"
29
41
>
30
42
<div
31
-
class="px-4 py-1 mr-1 text-black min-w-[80px] text-center relative rounded-t whitespace-nowrap
43
+
class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap
32
44
{{ if eq $.Active $key }}
33
45
{{ $activeTabStyles }}
34
46
{{ else }}
35
-
group-hover:bg-gray-200
47
+
group-hover:bg-gray-200 dark:group-hover:bg-gray-700
36
48
{{ end }}
37
49
"
38
50
>
39
-
{{ $key }}
40
-
{{ if not (isNil $meta) }}
41
-
<span class="bg-gray-200 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 }}
46
61
</div>
47
62
</nav>
48
63
<section
49
-
class="bg-white p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm"
64
+
class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white"
50
65
>
51
66
{{ block "repoContent" . }}{{ end }}
52
67
</section>
+8
-3
appview/pages/templates/layouts/topbar.html
+8
-3
appview/pages/templates/layouts/topbar.html
···
1
1
{{ define "layouts/topbar" }}
2
-
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white drop-shadow-sm">
2
+
<nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm">
3
3
<div class="container flex justify-between p-0">
4
4
<div id="left-items">
5
5
<a href="/" hx-boost="true" class="flex gap-2 font-semibold italic">
···
28
28
{{ didOrHandle .Did .Handle }}
29
29
</summary>
30
30
<div
31
-
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white border border-gray-200"
31
+
class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"
32
32
>
33
33
<a href="/{{ didOrHandle .Did .Handle }}">profile</a>
34
34
<a href="/knots">knots</a>
35
35
<a href="/settings">settings</a>
36
-
<a href="/logout" class="text-red-400 hover:text-red-700">logout</a>
36
+
<a href="#"
37
+
hx-post="/logout"
38
+
hx-swap="none"
39
+
class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
40
+
logout
41
+
</a>
37
42
</div>
38
43
</details>
39
44
{{ end }}
+27
-23
appview/pages/templates/repo/blob.html
+27
-23
appview/pages/templates/repo/blob.html
···
15
15
{{ $lines := split .Contents }}
16
16
{{ $tot_lines := len $lines }}
17
17
{{ $tot_chars := len (printf "%d" $tot_lines) }}
18
-
{{ $code_number_style := "text-gray-400 left-0 bg-white text-right mr-6 select-none inline-block w-12" }}
18
+
{{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }}
19
19
{{ $linkstyle := "no-underline hover:underline" }}
20
-
<div class="pb-2 text-base">
21
-
<div class="flex justify-between">
22
-
<div id="breadcrumbs">
20
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
21
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
22
+
<div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500">
23
23
{{ range $idx, $value := .BreadCrumbs }}
24
24
{{ if ne $idx (sub (len $.BreadCrumbs) 1) }}
25
25
<a
26
26
href="{{ index . 1 }}"
27
-
class="text-bold text-gray-500 {{ $linkstyle }}"
27
+
class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}"
28
28
>{{ index . 0 }}</a
29
29
>
30
30
/
31
31
{{ else }}
32
-
<span class="text-bold text-gray-500"
32
+
<span class="text-bold text-black dark:text-white"
33
33
>{{ index . 0 }}</span
34
34
>
35
35
{{ end }}
36
36
{{ end }}
37
37
</div>
38
-
<div id="file-info" class="text-gray-500 text-xs">
39
-
{{ .Lines }} lines
40
-
<span class="select-none px-2 [&:before]:content-['ยท']"></span>
41
-
{{ byteFmt .SizeHint }}
38
+
<div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0">
39
+
<span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span>
40
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
41
+
<span>{{ .Lines }} lines</span>
42
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
43
+
<span>{{ byteFmt .SizeHint }}</span>
44
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
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 }}
42
53
</div>
43
54
</div>
44
55
</div>
45
56
{{ if .IsBinary }}
46
-
<p class="text-center text-gray-400">
57
+
<p class="text-center text-gray-400 dark:text-gray-500">
47
58
This is a binary file and will not be displayed.
48
59
</p>
49
60
{{ else }}
50
-
<div class="overflow-auto relative text-ellipsis">
51
-
{{ range $idx, $line := $lines }}
52
-
{{ $linenr := add $idx 1 }}
53
-
<div class="flex">
54
-
<a href="#L{{ $linenr }}" id="L{{ $linenr }}" class="no-underline peer">
55
-
<span class="{{ $code_number_style }}"
56
-
style="min-width: {{ $tot_chars }}ch;">
57
-
{{ $linenr }}
58
-
</span>
59
-
</a>
60
-
<div class="whitespace-pre peer-target:bg-yellow-200">{{ $line | escapeHtml }}</div>
61
-
</div>
61
+
<div class="overflow-auto relative">
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>
62
66
{{ end }}
63
67
</div>
64
68
{{ end }}
+10
-27
appview/pages/templates/repo/commit.html
+10
-27
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
-
<section class="commit">
8
+
<section class="commit dark:text-white">
11
9
<div id="commit-message">
12
10
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
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>
20
18
21
19
<div class="flex items-center">
22
-
<p class="text-sm text-gray-500">
20
+
<p class="text-sm text-gray-500 dark:text-gray-300">
23
21
{{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }}
24
22
25
23
{{ if $didOrHandle }}
26
-
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500">{{ $didOrHandle }}</a>
24
+
<a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a>
27
25
{{ else }}
28
-
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a>
26
+
<a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a>
29
27
{{ end }}
30
28
<span class="px-1 select-none before:content-['\00B7']"></span>
31
29
{{ timeFmt $commit.Author.When }}
32
30
<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
-
<span class="px-1 select-none before:content-['\00B7']"></span>
35
31
</p>
36
32
37
-
<p class="flex items-center text-sm text-gray-500">
38
-
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.This 0 8 }}</a>
33
+
<p class="flex items-center text-sm text-gray-500 dark:text-gray-300">
34
+
<a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a>
39
35
{{ if $commit.Parent }}
40
36
{{ i "arrow-left" "w-3 h-3 mx-1" }}
41
-
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.Parent 0 8 }}</a>
37
+
<a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a>
42
38
{{ end }}
43
39
</p>
44
40
</div>
45
-
46
-
<div class="diff-stat">
47
-
<br>
48
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
49
-
{{ range $diff }}
50
-
<ul>
51
-
{{ if .IsDelete }}
52
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
53
-
{{ else }}
54
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
55
-
{{ end }}
56
-
</ul>
57
-
{{ end }}
58
-
</div>
41
+
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}}
+2
-21
appview/pages/templates/repo/empty.html
+2
-21
appview/pages/templates/repo/empty.html
···
2
2
3
3
{{ define "repoContent" }}
4
4
<main>
5
-
<p class="text-center pt-5 text-gray-400">
5
+
<p class="text-center pt-5 text-gray-400 dark:text-gray-500">
6
6
This is an empty repository. Push some commits here.
7
7
</p>
8
8
</main>
9
9
{{ end }}
10
10
11
11
{{ define "repoAfter" }}
12
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto">
13
-
<strong>push</strong>
14
-
<div class="py-2">
15
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
16
-
</div>
17
-
<strong>clone</strong>
18
-
19
-
20
-
<div class="flex flex-col gap-2">
21
-
<div class="pt-2 flex flex-row gap-2">
22
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
23
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
24
-
</div>
25
-
<div class="pt-2 flex flex-row gap-2">
26
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span><code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
27
-
</div>
28
-
</div>
29
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
30
-
</section>
31
-
12
+
{{ template "repo/fragments/cloneInstructions" . }}
32
13
{{ end }}
+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 }}
+175
appview/pages/templates/repo/fragments/diff.html
+175
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
+
{{ $diff := $diff.Diff }}
7
+
8
+
{{ $this := $commit.This }}
9
+
{{ $parent := $commit.Parent }}
10
+
11
+
<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">
12
+
<div class="diff-stat">
13
+
<div class="flex gap-2 items-center">
14
+
<strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
15
+
{{ block "statPill" $stat }} {{ end }}
16
+
</div>
17
+
<div class="overflow-x-auto">
18
+
{{ range $diff }}
19
+
<ul class="dark:text-gray-200">
20
+
{{ if .IsDelete }}
21
+
<li><a href="#file-{{ .Name.Old }}" class="dark:hover:text-gray-300">{{ .Name.Old }}</a></li>
22
+
{{ else }}
23
+
<li><a href="#file-{{ .Name.New }}" class="dark:hover:text-gray-300">{{ .Name.New }}</a></li>
24
+
{{ end }}
25
+
</ul>
26
+
{{ end }}
27
+
</div>
28
+
</div>
29
+
</section>
30
+
31
+
{{ $last := sub (len $diff) 1 }}
32
+
{{ range $idx, $hunk := $diff }}
33
+
{{ with $hunk }}
34
+
<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">
35
+
<div id="file-{{ .Name.New }}">
36
+
<div id="diff-file">
37
+
<details open>
38
+
<summary class="list-none cursor-pointer sticky top-0">
39
+
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
40
+
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto" style="direction: rtl;">
41
+
{{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }}
42
+
43
+
<div class="flex gap-2 items-center" style="direction: ltr;">
44
+
{{ if .IsNew }}
45
+
<span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span>
46
+
{{ else if .IsDelete }}
47
+
<span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span>
48
+
{{ else if .IsCopy }}
49
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span>
50
+
{{ else if .IsRename }}
51
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span>
52
+
{{ else }}
53
+
<span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span>
54
+
{{ end }}
55
+
56
+
{{ block "statPill" .Stats }} {{ end }}
57
+
58
+
{{ if .IsDelete }}
59
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}>
60
+
{{ .Name.Old }}
61
+
</a>
62
+
{{ else if (or .IsCopy .IsRename) }}
63
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}>
64
+
{{ .Name.Old }}
65
+
</a>
66
+
{{ i "arrow-right" "w-4 h-4" }}
67
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
68
+
{{ .Name.New }}
69
+
</a>
70
+
{{ else }}
71
+
<a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}>
72
+
{{ .Name.New }}
73
+
</a>
74
+
{{ end }}
75
+
</div>
76
+
</div>
77
+
78
+
{{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }}
79
+
<div id="right-side-items" class="p-2 flex items-center">
80
+
<a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a>
81
+
{{ if gt $idx 0 }}
82
+
{{ $prev := index $diff (sub $idx 1) }}
83
+
<a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a>
84
+
{{ end }}
85
+
86
+
{{ if lt $idx $last }}
87
+
{{ $next := index $diff (add $idx 1) }}
88
+
<a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a>
89
+
{{ end }}
90
+
</div>
91
+
92
+
</div>
93
+
</summary>
94
+
95
+
<div class="transition-all duration-700 ease-in-out">
96
+
{{ if .IsDelete }}
97
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
98
+
This file has been deleted.
99
+
</p>
100
+
{{ else if .IsCopy }}
101
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
102
+
This file has been copied.
103
+
</p>
104
+
{{ else if .IsRename }}
105
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
106
+
This file has been renamed.
107
+
</p>
108
+
{{ else if .IsBinary }}
109
+
<p class="text-center text-gray-400 dark:text-gray-500 p-4">
110
+
This is a binary file and will not be displayed.
111
+
</p>
112
+
{{ else }}
113
+
{{ $name := .Name.New }}
114
+
<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>
115
+
{{- $oldStart := .OldPosition -}}
116
+
{{- $newStart := .NewPosition -}}
117
+
{{- $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 " -}}
118
+
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
119
+
{{- $lineNrSepStyle1 := "" -}}
120
+
{{- $lineNrSepStyle2 := "pr-2" -}}
121
+
{{- range .Lines -}}
122
+
{{- if eq .Op.String "+" -}}
123
+
<div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center">
124
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
125
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
126
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
127
+
<div class="px-2">{{ .Line }}</div>
128
+
</div>
129
+
{{- $newStart = add64 $newStart 1 -}}
130
+
{{- end -}}
131
+
{{- if eq .Op.String "-" -}}
132
+
<div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center">
133
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
134
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
135
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
136
+
<div class="px-2">{{ .Line }}</div>
137
+
</div>
138
+
{{- $oldStart = add64 $oldStart 1 -}}
139
+
{{- end -}}
140
+
{{- if eq .Op.String " " -}}
141
+
<div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center">
142
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
143
+
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
144
+
<div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div>
145
+
<div class="px-2">{{ .Line }}</div>
146
+
</div>
147
+
{{- $newStart = add64 $newStart 1 -}}
148
+
{{- $oldStart = add64 $oldStart 1 -}}
149
+
{{- end -}}
150
+
{{- end -}}
151
+
{{- end -}}</div></div></pre>
152
+
{{- end -}}
153
+
</div>
154
+
155
+
</details>
156
+
157
+
</div>
158
+
</div>
159
+
</section>
160
+
{{ end }}
161
+
{{ end }}
162
+
{{ end }}
163
+
164
+
{{ define "statPill" }}
165
+
<div class="flex items-center font-mono text-sm">
166
+
{{ if and .Insertions .Deletions }}
167
+
<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>
168
+
<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>
169
+
{{ else if .Insertions }}
170
+
<span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span>
171
+
{{ else if .Deletions }}
172
+
<span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span>
173
+
{{ end }}
174
+
</div>
175
+
{{ 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 }}
+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 }}
+201
-197
appview/pages/templates/repo/index.html
+201
-197
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"
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"
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">
72
-
{{ $containerstyle := "py-1" }}
73
-
{{ $linkstyle := "no-underline hover:underline" }}
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"
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"
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
-
120
138
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"
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"
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"
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">
157
-
<span class="font-mono">
158
-
<a
159
-
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
160
-
class="text-gray-500 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 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 text-black 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 w-full mx-auto overflow-auto {{ if not .Raw }} prose {{ end }}">
200
-
<article class="{{ if .Raw }}whitespace-pre{{end}}">
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 }}">
201
232
{{ if .Raw }}
202
-
<pre>{{ .HTMLReadme }}</pre>
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
+
>
203
238
{{ else }}
204
239
{{ .HTMLReadme }}
205
240
{{ end }}
···
207
242
</section>
208
243
{{- end -}}
209
244
210
-
211
-
<section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto flex flex-col gap-4">
212
-
<div class="flex flex-col gap-2">
213
-
<strong>push</strong>
214
-
<div class="md:pl-4 overflow-x-auto whitespace-nowrap">
215
-
<code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
216
-
</div>
217
-
</div>
218
-
219
-
<div class="flex flex-col gap-2">
220
-
<strong>clone</strong>
221
-
<div class="md:pl-4 flex flex-col gap-2">
222
-
223
-
<div class="flex items-center gap-3">
224
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span>
225
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
226
-
<code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code>
227
-
</div>
228
-
</div>
229
-
230
-
<div class="flex items-center gap-3">
231
-
<span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span>
232
-
<div class="overflow-x-auto whitespace-nowrap flex-1">
233
-
<code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code>
234
-
</div>
235
-
</div>
236
-
</div>
237
-
</div>
238
-
239
-
240
-
<p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p>
241
-
</section>
245
+
{{ template "repo/fragments/cloneInstructions" . }}
242
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 }}
+123
-72
appview/pages/templates/repo/issues/issue.html
+123
-72
appview/pages/templates/repo/issues/issue.html
···
1
-
{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} ·{{ .RepoInfo.FullName }}{{ end }}
1
+
{{ define "title" }}{{ .Issue.Title }} · issue #{{ .Issue.IssueId }} · {{ .RepoInfo.FullName }}{{ end }}
2
2
3
3
{{ define "repoContent" }}
4
4
<header class="pb-4">
5
5
<h1 class="text-2xl">
6
6
{{ .Issue.Title }}
7
-
<span class="text-gray-500">#{{ .Issue.IssueId }}</span>
7
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span>
8
8
</h1>
9
9
</header>
10
10
11
-
{{ $bgColor := "bg-gray-800" }}
11
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
12
12
{{ $icon := "ban" }}
13
13
{{ if eq .State "open" }}
14
-
{{ $bgColor = "bg-green-600" }}
14
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
15
15
{{ $icon = "circle-dot" }}
16
16
{{ end }}
17
17
18
18
<section class="mt-2">
19
19
<div class="inline-flex items-center gap-2">
20
20
<div id="state"
21
-
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }} text-sm">
21
+
class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}">
22
22
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
23
23
<span class="text-white">{{ .State }}</span>
24
24
</div>
25
-
<span class="text-gray-500 text-sm">
25
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
26
26
opened by
27
27
{{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }}
28
28
<a href="/{{ $owner }}" class="no-underline hover:underline"
29
29
>{{ $owner }}</a
30
30
>
31
31
<span class="px-1 select-none before:content-['\00B7']"></span>
32
-
<time>{{ .Issue.Created | timeFmt }}</time>
32
+
<time title="{{ .Issue.Created | longTimeFmt }}">
33
+
{{ .Issue.Created | timeFmt }}
34
+
</time>
33
35
</span>
34
36
</div>
35
37
36
38
{{ if .Issue.Body }}
37
-
<article id="body" class="mt-4 prose">
39
+
<article id="body" class="mt-8 prose dark:prose-invert">
38
40
{{ .Issue.Body | markdown }}
39
41
</article>
40
42
{{ end }}
···
42
44
{{ end }}
43
45
44
46
{{ define "repoAfter" }}
45
-
{{ if gt (len .Comments) 0 }}
46
-
<section id="comments" class="mt-8 space-y-4 relative">
47
+
<section id="comments" class="my-2 mt-2 space-y-2 relative">
47
48
{{ range $index, $comment := .Comments }}
48
49
<div
49
50
id="comment-{{ .CommentId }}"
50
-
class="rounded bg-white px-6 py-4 relative"
51
-
>
52
-
{{ if eq $index 0 }}
53
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
54
-
{{ else }}
55
-
<div class="absolute left-8 -top-4 w-px h-4 bg-gray-300" ></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>
56
54
{{ end }}
57
-
<div class="flex items-center gap-2 mb-2 text-gray-500">
58
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
59
-
<span class="text-sm">
60
-
<a
61
-
href="/{{ $owner }}"
62
-
class="no-underline hover:underline"
63
-
>{{ $owner }}</a
64
-
>
65
-
</span>
66
-
67
-
<span class="before:content-['ยท']"></span>
68
-
<a
69
-
href="#{{ .CommentId }}"
70
-
class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline"
71
-
id="{{ .CommentId }}"
72
-
>
73
-
{{ .Created | timeFmt }}
74
-
</a>
75
-
</div>
76
-
<div class="prose">
77
-
{{ .Body | markdown }}
78
-
</div>
55
+
{{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}}
79
56
</div>
80
57
{{ end }}
81
58
</section>
82
-
{{ end }}
83
59
84
60
{{ block "newComment" . }} {{ end }}
85
61
86
-
{{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }}
87
-
{{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }}
88
-
{{ if or $isIssueAuthor $isRepoCollaborator }}
89
-
{{ $action := "close" }}
90
-
{{ $icon := "circle-x" }}
91
-
{{ $hoverColor := "red" }}
92
-
{{ if eq .State "closed" }}
93
-
{{ $action = "reopen" }}
94
-
{{ $icon = "circle-dot" }}
95
-
{{ $hoverColor = "green" }}
96
-
{{ end }}
97
-
<form
98
-
hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}"
99
-
hx-swap="none"
100
-
class="mt-8"
101
-
>
102
-
<button type="submit" class="btn hover:bg-{{ $hoverColor }}-300">
103
-
{{ i $icon "w-4 h-4 mr-2" }}
104
-
<span class="text-black">{{ $action }}</span>
105
-
</button>
106
-
<div id="issue-action" class="error"></div>
107
-
</form>
108
-
{{ end }}
109
62
{{ end }}
110
63
111
64
{{ define "newComment" }}
112
65
{{ if .LoggedInUser }}
113
-
<div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8">
114
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
115
-
<div class="text-sm text-gray-500">
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">
116
73
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
117
74
</div>
118
-
<form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment">
119
75
<textarea
76
+
id="comment-textarea"
120
77
name="body"
121
-
class="w-full p-2 rounded border border-gray-200"
122
-
placeholder="Add to the discussion..."
78
+
class="w-full p-2 rounded border border-gray-200 dark:border-gray-700"
79
+
placeholder="Add to the discussion. Markdown is supported."
80
+
onkeyup="updateCommentForm()"
123
81
></textarea>
124
-
<button type="submit" class="btn mt-2">comment</button>
125
82
<div id="issue-comment"></div>
126
-
</form>
83
+
<div id="issue-action" class="error"></div>
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>
127
178
</div>
179
+
</form>
128
180
{{ else }}
129
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8">
130
-
<div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div>
181
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
131
182
<a href="/login" class="underline">login</a> to join the discussion
132
183
</div>
133
184
{{ end }}
+10
-10
appview/pages/templates/repo/issues/issues.html
+10
-10
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" 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>
···
23
23
{{ define "repoAfter" }}
24
24
<div class="flex flex-col gap-2 mt-2">
25
25
{{ range .Issues }}
26
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4">
26
+
<div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700">
27
27
<div class="pb-2">
28
28
<a
29
29
href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}"
···
33
33
<span class="text-gray-500">#{{ .IssueId }}</span>
34
34
</a>
35
35
</div>
36
-
<p class="text-sm text-gray-500">
37
-
{{ $bgColor := "bg-gray-800" }}
36
+
<p class="text-sm text-gray-500 dark:text-gray-400">
37
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
38
38
{{ $icon := "ban" }}
39
39
{{ $state := "closed" }}
40
40
{{ if .Open }}
41
-
{{ $bgColor = "bg-green-600" }}
41
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
42
42
{{ $icon = "circle-dot" }}
43
43
{{ $state = "open" }}
44
44
{{ end }}
45
45
46
46
<span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm">
47
-
{{ i $icon "w-3 h-3 mr-1.5 text-white" }}
48
-
<span class="text-white">{{ $state }}</span>
47
+
{{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }}
48
+
<span class="text-white dark:text-white">{{ $state }}</span>
49
49
</span>
50
50
51
51
<span>
···
64
64
{{ if eq .Metadata.CommentCount 1 }}
65
65
{{ $s = "" }}
66
66
{{ end }}
67
-
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500">{{ .Metadata.CommentCount }} comment{{$s}}</a>
67
+
<a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a>
68
68
</span>
69
69
</p>
70
70
</div>
+1
-1
appview/pages/templates/repo/issues/new.html
+1
-1
appview/pages/templates/repo/issues/new.html
+17
-17
appview/pages/templates/repo/log.html
+17
-17
appview/pages/templates/repo/log.html
···
5
5
{{ $commit := index .Commits 0 }}
6
6
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
7
7
<div>
8
-
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}">
8
+
<a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white">
9
9
<p class="pb-5">{{ index $messageParts 0 }}</p>
10
10
{{ if gt (len $messageParts) 1 }}
11
11
<p class="mt-1 text-sm cursor-text pb-5">
···
15
15
</a>
16
16
</div>
17
17
18
-
<div class="text-sm text-gray-500">
18
+
<div class="text-sm text-gray-500 dark:text-gray-400">
19
19
<span class="font-mono">
20
20
<a
21
21
href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"
22
-
class="text-gray-500 no-underline hover:underline"
22
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
23
23
>{{ slice $commit.Hash.String }}</a
24
24
>
25
25
</span>
···
29
29
{{ if $didOrHandle }}
30
30
<a
31
31
href="/{{ $didOrHandle }}"
32
-
class="text-gray-500 no-underline hover:underline"
32
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
33
33
>{{ $didOrHandle }}</a
34
34
>
35
35
{{ else }}
36
36
<a
37
37
href="mailto:{{ $commit.Author.Email }}"
38
-
class="text-gray-500 no-underline hover:underline"
38
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
39
39
>{{ $commit.Author.Name }}</a
40
40
>
41
41
{{ end }}
···
51
51
{{ define "repoAfter" }}
52
52
<main>
53
53
<div id="commit-log" class="flex-1 relative">
54
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
54
+
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
55
55
{{ $end := length .Commits }}
56
56
{{ $commits := subslice .Commits 1 $end }}
57
57
{{ range $commits }}
58
58
<div class="flex flex-row justify-between items-center">
59
59
<div
60
-
class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white"
60
+
class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white dark:bg-gray-800"
61
61
>
62
62
<div id="commit-message">
63
63
{{ $messageParts := splitN .Message "\n\n" 2 }}
···
66
66
<div>
67
67
<a
68
68
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
69
-
class="inline no-underline hover:underline"
69
+
class="inline no-underline hover:underline dark:text-white"
70
70
>{{ index $messageParts 0 }}</a
71
71
>
72
72
{{ if gt (len $messageParts) 1 }}
73
73
74
74
<button
75
-
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded"
75
+
class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded"
76
76
hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"
77
77
>
78
78
{{ i "ellipsis" "w-3 h-3" }}
···
81
81
</div>
82
82
{{ if gt (len $messageParts) 1 }}
83
83
<p
84
-
class="hidden mt-1 text-sm cursor-text pb-2"
84
+
class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"
85
85
>
86
-
{{ nl2br (unwrapText (index $messageParts 1)) }}
86
+
{{ nl2br (index $messageParts 1) }}
87
87
</p>
88
88
{{ end }}
89
89
</div>
90
90
</div>
91
91
</div>
92
92
93
-
<div class="text-sm text-gray-500 mt-3">
93
+
<div class="text-sm text-gray-500 dark:text-gray-400 mt-3">
94
94
<span class="font-mono">
95
95
<a
96
96
href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}"
97
-
class="text-gray-500 no-underline hover:underline"
97
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
98
98
>{{ slice .Hash.String 0 8 }}</a
99
99
>
100
100
</span>
···
106
106
{{ if $didOrHandle }}
107
107
<a
108
108
href="/{{ $didOrHandle }}"
109
-
class="text-gray-500 no-underline hover:underline"
109
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
110
110
>{{ $didOrHandle }}</a
111
111
>
112
112
{{ else }}
113
113
<a
114
114
href="mailto:{{ .Author.Email }}"
115
-
class="text-gray-500 no-underline hover:underline"
115
+
class="text-gray-500 dark:text-gray-400 no-underline hover:underline"
116
116
>{{ .Author.Name }}</a
117
117
>
118
118
{{ end }}
···
131
131
<div class="flex justify-end mt-4 gap-2">
132
132
{{ if gt .Page 1 }}
133
133
<a
134
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
134
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
135
135
hx-boost="true"
136
136
onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'"
137
137
>
···
144
144
145
145
{{ if eq $commits_len 30 }}
146
146
<a
147
-
class="btn flex items-center gap-2 no-underline hover:no-underline"
147
+
class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700"
148
148
hx-boost="true"
149
149
onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'"
150
150
>
+13
-13
appview/pages/templates/repo/new.html
+13
-13
appview/pages/templates/repo/new.html
···
2
2
3
3
{{ define "content" }}
4
4
<div class="p-6">
5
-
<p class="text-xl font-bold">Create a new repository</p>
5
+
<p class="text-xl font-bold dark:text-white">Create a new repository</p>
6
6
</div>
7
-
<div class="p-6 bg-white drop-shadow-sm rounded">
7
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
8
8
<form hx-post="/repo/new" class="space-y-12" hx-swap="none">
9
9
<div class="space-y-2">
10
-
<label for="name" class="-mb-1">Repository name</label>
10
+
<label for="name" class="-mb-1 dark:text-white">Repository name</label>
11
11
<input
12
12
type="text"
13
13
id="name"
14
14
name="name"
15
15
required
16
-
class="w-full max-w-md"
16
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
17
17
/>
18
-
<p class="text-sm text-gray-500">All repositories are publicly visible.</p>
18
+
<p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p>
19
19
20
-
<label for="branch">Default branch</label>
20
+
<label for="branch" class="dark:text-white">Default branch</label>
21
21
<input
22
22
type="text"
23
23
id="branch"
24
24
name="branch"
25
25
value="main"
26
26
required
27
-
class="w-full max-w-md"
27
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
28
28
/>
29
29
30
-
<label for="description">Description</label>
30
+
<label for="description" class="dark:text-white">Description</label>
31
31
<input
32
32
type="text"
33
33
id="description"
34
34
name="description"
35
-
class="w-full max-w-md"
35
+
class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600"
36
36
/>
37
37
</div>
38
38
39
39
<fieldset class="space-y-3">
40
-
<legend>Select a knot</legend>
40
+
<legend class="dark:text-white">Select a knot</legend>
41
41
<div class="space-y-2">
42
42
<div class="flex flex-col">
43
43
{{ range .Knots }}
···
49
49
class="mr-2"
50
50
id="domain-{{ . }}"
51
51
/>
52
-
<span>{{ . }}</span>
52
+
<span class="dark:text-white">{{ . }}</span>
53
53
</div>
54
54
{{ else }}
55
-
<p>No knots available.</p>
55
+
<p class="dark:text-white">No knots available.</p>
56
56
{{ end }}
57
57
</div>
58
58
</div>
59
-
<p class="text-sm text-gray-500">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p>
59
+
<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>
60
60
</fieldset>
61
61
62
62
<div class="space-y-2">
+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
+
+20
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
+20
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
+
{{ end }}
+42
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
+42
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
+
{{ 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 }}
+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
+
+14
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
+14
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
1
+
{{ define "repo/pulls/fragments/pullPatchUpload" }}
2
+
<div id="patch-upload">
3
+
<textarea
4
+
name="patch"
5
+
id="patch"
6
+
rows="12"
7
+
class="w-full resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600"
8
+
placeholder="diff --git a/file.txt b/file.txt
9
+
index 1234567..abcdefg 100644
10
+
--- a/file.txt
11
+
+++ b/file.txt"
12
+
></textarea>
13
+
</div>
14
+
{{ 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 }}
+90
-53
appview/pages/templates/repo/pulls/new.html
+90
-53
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">
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">
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 px-1 py-0.5 rounded text-gray-800 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">write a title</label>
24
-
<input type="text" name="title" id="title" class="w-full" />
10
+
<div>
11
+
<label for="title" class="dark:text-white">write a title</label>
12
+
<input type="text" name="title" id="title" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" />
13
+
</div>
25
14
26
-
<label for="targetBranch">select a target branch</label>
27
-
<p class="text-gray-500">
28
-
The branch you want to make your change against.
29
-
</p>
30
-
<select
31
-
name="targetBranch"
32
-
class="p-1 mb-2 border border-gray-200 bg-white"
33
-
>
34
-
<option disabled selected>select a branch</option>
35
-
{{ range .Branches }}
36
-
<option value="{{ .Reference.Name }}" class="py-1">
37
-
{{ .Reference.Name }}
38
-
</option>
39
-
{{ end }}
40
-
</select>
41
-
<label for="body">add a description</label>
42
-
<textarea
43
-
name="body"
44
-
id="body"
45
-
rows="6"
46
-
class="w-full resize-y"
47
-
placeholder="Describe your change. Markdown is supported."
15
+
<div>
16
+
<label for="body" class="dark:text-white">add a description</label>
17
+
<textarea
18
+
name="body"
19
+
id="body"
20
+
rows="6"
21
+
class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600"
22
+
placeholder="Describe your change. Markdown is supported."
48
23
></textarea>
24
+
</div>
49
25
50
-
<div class="mt-4">
51
-
<label for="patch">paste your patch here</label>
52
-
<textarea
53
-
name="patch"
54
-
id="patch"
55
-
rows="10"
56
-
class="w-full resize-y font-mono"
57
-
placeholder="Paste your git diff output here."
58
-
></textarea>
59
-
</div>
60
-
</div>
61
-
<div>
62
-
<button type="submit" class="btn">create</button>
63
-
</div>
26
+
27
+
<label>configure your pull request</label>
28
+
29
+
<p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p>
30
+
<div class="pb-2">
31
+
<select
32
+
required
33
+
name="targetBranch"
34
+
class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600"
35
+
>
36
+
<option disabled selected>target branch</option>
37
+
{{ range .Branches }}
38
+
<option value="{{ .Reference.Name }}" class="py-1">
39
+
{{ .Reference.Name }}
40
+
</option>
41
+
{{ end }}
42
+
</select>
43
+
</div>
44
+
45
+
<p>Then, choose a pull strategy.</p>
46
+
<nav class="flex space-x-4 items-end">
47
+
<button
48
+
type="button"
49
+
class="px-3 py-2 pb-2 btn"
50
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload"
51
+
hx-target="#patch-strategy"
52
+
hx-swap="innerHTML"
53
+
>
54
+
paste patch
55
+
</button>
56
+
57
+
{{ if .RepoInfo.Roles.IsPushAllowed }}
58
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
59
+
or
60
+
</span>
61
+
<button
62
+
type="button"
63
+
class="px-3 py-2 pb-2 btn"
64
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches"
65
+
hx-target="#patch-strategy"
66
+
hx-swap="innerHTML"
67
+
>
68
+
compare branches
69
+
</button>
70
+
{{ end }}
71
+
72
+
<span class="text-sm text-gray-500 dark:text-gray-400 pb-2">
73
+
or
74
+
</span>
75
+
<button
76
+
type="button"
77
+
class="px-3 py-2 pb-2 btn"
78
+
hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks"
79
+
hx-target="#patch-strategy"
80
+
hx-swap="innerHTML"
81
+
>
82
+
compare forks
83
+
</button>
84
+
</nav>
85
+
86
+
<section id="patch-strategy">
87
+
{{ template "repo/pulls/fragments/pullPatchUpload" . }}
88
+
</section>
89
+
90
+
<div class="flex justify-start items-center gap-2 mt-4">
91
+
<button type="submit" class="btn flex items-center gap-2">
92
+
{{ i "git-pull-request-create" "w-4 h-4" }}
93
+
create pull
94
+
</button>
95
+
</div>
96
+
64
97
</div>
65
-
<div id="pull" class="error"></div>
98
+
<div id="pull" class="error dark:text-red-300"></div>
66
99
</form>
67
100
{{ end }}
101
+
102
+
{{ define "repoAfter" }}
103
+
<div id="patch-preview" class="error dark:text-red-300"></div>
104
+
{{ end }}
+13
-24
appview/pages/templates/repo/pulls/patch.html
+13
-24
appview/pages/templates/repo/pulls/patch.html
···
1
1
{{ define "title" }}
2
-
{{ $oneIndexedRound := add .Round 1 }}
3
-
patch of {{ .Pull.Title }} · round #{{ $oneIndexedRound }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
2
+
patch of {{ .Pull.Title }} · round #{{ .Round }} · pull #{{ .Pull.PullId }} · {{ .RepoInfo.FullName }}
4
3
{{ end }}
5
4
6
5
{{ define "content" }}
7
-
{{ $oneIndexedRound := add .Round 1 }}
8
6
{{ $stat := .Diff.Stat }}
9
-
<div class="rounded drop-shadow-sm bg-white py-4 px-6">
7
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white">
10
8
<header class="pb-2">
11
9
<div class="flex gap-3 items-center mb-3">
12
10
<a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium">
···
14
12
back
15
13
</a>
16
14
<span class="select-none before:content-['\00B7']"></span>
17
-
round #{{ $oneIndexedRound }}
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>
18
20
</div>
19
-
<div class="border-t border-gray-200 my-2"></div>
21
+
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
20
22
<h1 class="text-2xl mt-3">
21
23
{{ .Pull.Title }}
22
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
24
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
23
25
</h1>
24
26
</header>
25
27
···
43
45
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
44
46
<span class="text-white">{{ .Pull.State.String }}</span>
45
47
</div>
46
-
<span class="text-gray-500 text-sm">
48
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
47
49
opened by
48
50
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
49
51
<a href="/{{ $owner }}" class="no-underline hover:underline"
···
53
55
<time>{{ .Pull.Created | timeFmt }}</time>
54
56
<span class="select-none before:content-['\00B7']"></span>
55
57
<span>targeting branch
56
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
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">
57
59
{{ .Pull.TargetBranch }}
58
60
</span>
59
61
</span>
···
61
63
</div>
62
64
63
65
{{ if .Pull.Body }}
64
-
<article id="body" class="mt-2 prose">
66
+
<article id="body" class="mt-2 prose dark:prose-invert">
65
67
{{ .Pull.Body | markdown }}
66
68
</article>
67
69
{{ end }}
68
70
</section>
69
71
70
-
<div id="diff-stat">
71
-
<br>
72
-
<strong class="text-sm uppercase mb-4">Changed files</strong>
73
-
{{ range .Diff.Diff }}
74
-
<ul>
75
-
{{ if .IsDelete }}
76
-
<li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li>
77
-
{{ else }}
78
-
<li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li>
79
-
{{ end }}
80
-
</ul>
81
-
{{ end }}
82
-
</div>
83
72
</div>
84
73
85
74
<section>
86
-
{{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }}
75
+
{{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }}
87
76
</section>
88
77
{{ end }}
+75
-45
appview/pages/templates/repo/pulls/pull.html
+75
-45
appview/pages/templates/repo/pulls/pull.html
···
4
4
5
5
{{ define "repoContent" }}
6
6
<header class="pb-4">
7
-
<h1 class="text-2xl">
7
+
<h1 class="text-2xl dark:text-white">
8
8
{{ .Pull.Title }}
9
-
<span class="text-gray-500">#{{ .Pull.PullId }}</span>
9
+
<span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span>
10
10
</h1>
11
11
</header>
12
12
13
-
{{ $bgColor := "bg-gray-800" }}
13
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
14
14
{{ $icon := "ban" }}
15
15
16
16
{{ if .Pull.State.IsOpen }}
17
-
{{ $bgColor = "bg-green-600" }}
17
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
18
18
{{ $icon = "git-pull-request" }}
19
19
{{ else if .Pull.State.IsMerged }}
20
-
{{ $bgColor = "bg-purple-600" }}
20
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
21
21
{{ $icon = "git-merge" }}
22
22
{{ end }}
23
23
24
-
<section>
24
+
<section class="mt-2">
25
25
<div class="flex items-center gap-2">
26
26
<div
27
27
id="state"
···
30
30
{{ i $icon "w-4 h-4 mr-1.5 text-white" }}
31
31
<span class="text-white">{{ .Pull.State.String }}</span>
32
32
</div>
33
-
<span class="text-gray-500 text-sm">
33
+
<span class="text-gray-500 dark:text-gray-400 text-sm">
34
34
opened by
35
35
{{ $owner := index $.DidHandleMap .Pull.OwnerDid }}
36
36
<a href="/{{ $owner }}" class="no-underline hover:underline"
···
39
39
<span class="select-none before:content-['\00B7']"></span>
40
40
<time>{{ .Pull.Created | timeFmt }}</time>
41
41
<span class="select-none before:content-['\00B7']"></span>
42
-
<span>targeting branch
43
-
<span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center">
44
-
{{ .Pull.TargetBranch }}
42
+
<span>
43
+
targeting
44
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
45
+
<a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a>
45
46
</span>
46
47
</span>
48
+
{{ if not .Pull.IsPatchBased }}
49
+
<span>from
50
+
{{ if not .Pull.IsBranchBased }}
51
+
<a href="/{{ $owner }}/{{ .PullSourceRepo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSourceRepo.Name }}</a>
52
+
{{ end }}
53
+
54
+
{{ $fullRepo := .RepoInfo.FullName }}
55
+
{{ if not .Pull.IsBranchBased }}
56
+
{{ $fullRepo = printf "%s/%s" $owner .PullSourceRepo.Name }}
57
+
{{ end }}
58
+
<span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center">
59
+
<a href="/{{ $fullRepo }}/tree/{{ .Pull.PullSource.Branch }}" class="no-underline hover:underline">{{ .Pull.PullSource.Branch }}</a>
60
+
</span>
61
+
</span>
62
+
{{ end }}
47
63
</span>
48
64
</div>
49
65
50
66
{{ if .Pull.Body }}
51
-
<article id="body" class="mt-2 prose">
67
+
<article id="body" class="mt-8 prose dark:prose-invert">
52
68
{{ .Pull.Body | markdown }}
53
69
</article>
54
70
{{ end }}
···
74
90
{{ range $idx, $item := .Pull.Submissions }}
75
91
{{ $diff := $item.AsNiceDiff $targetBranch }}
76
92
{{ with $item }}
77
-
{{ $oneIndexedRound := add .RoundNumber 1 }}
78
93
<details {{ if eq $idx $lastIdx }}open{{ end }}>
79
-
<summary id="round-#{{ $oneIndexedRound }}" class="list-none cursor-pointer">
94
+
<summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer">
80
95
<div class="flex flex-wrap gap-2 items-center">
81
96
<!-- round number -->
82
-
<div class="rounded bg-white drop-shadow-sm px-3 py-2">
83
-
#{{ $oneIndexedRound }}
97
+
<div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white">
98
+
#{{ .RoundNumber }}
84
99
</div>
85
100
<!-- round summary -->
86
-
<div class="rounded drop-shadow-sm bg-white p-2 text-gray-500">
101
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400">
87
102
<span>
88
103
{{ $owner := index $.DidHandleMap $.Pull.OwnerDid }}
89
104
{{ $re := "re" }}
···
93
108
<span class="hidden md:inline">{{$re}}submitted</span>
94
109
by <a href="/{{ $owner }}">{{ $owner }}</a>
95
110
<span class="select-none before:content-['\00B7']"></span>
96
-
<a class="text-gray-500 hover:text-gray-500" href="#round-#{{ $oneIndexedRound }}"><time>{{ .Created | shortTimeFmt }}</time></a>
111
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a>
97
112
<span class="select-none before:content-['ยท']"></span>
98
113
{{ $s := "s" }}
99
114
{{ if eq (len .Comments) 1 }}
···
112
127
</summary>
113
128
<div class="md:pl-12 flex flex-col gap-2 mt-2 relative">
114
129
{{ range .Comments }}
115
-
<div id="comment-{{.ID}}" class="bg-white rounded drop-shadow-sm py-2 px-4 relative w-fit">
116
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
117
-
<div class="text-sm text-gray-500">
130
+
<div id="comment-{{.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit">
131
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
132
+
<div class="text-sm text-gray-500 dark:text-gray-400">
118
133
{{ $owner := index $.DidHandleMap .OwnerDid }}
119
134
<a href="/{{$owner}}">{{$owner}}</a>
120
135
<span class="before:content-['ยท']"></span>
121
-
<a class="text-gray-500 hover:text-gray-500" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a>
136
+
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a>
122
137
</div>
123
-
<div class="prose">
138
+
<div class="prose dark:prose-invert">
124
139
{{ .Body | markdown }}
125
140
</div>
126
141
</div>
···
128
143
129
144
{{ if eq $lastIdx .RoundNumber }}
130
145
{{ block "mergeStatus" $ }} {{ end }}
146
+
{{ block "resubmitStatus" $ }} {{ end }}
131
147
{{ end }}
132
148
133
149
{{ if $.LoggedInUser }}
134
-
{{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }}
150
+
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }}
135
151
{{ else }}
136
-
<div class="bg-white rounded drop-shadow-sm px-6 py-4 w-fit">
137
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
152
+
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
153
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
138
154
<a href="/login" class="underline">login</a> to join the discussion
139
155
</div>
140
156
{{ end }}
141
157
</div>
142
158
</details>
143
-
<hr class="md:hidden"/>
159
+
<hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/>
144
160
{{ end }}
145
161
{{ end }}
146
162
{{ end }}
147
163
148
164
{{ define "mergeStatus" }}
149
165
{{ if .Pull.State.IsClosed }}
150
-
<div class="bg-gray-50 border border-black rounded drop-shadow-sm px-6 py-2 relative w-fit">
151
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
152
-
<div class="flex items-center gap-2 text-black">
166
+
<div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
167
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
168
+
<div class="flex items-center gap-2 text-black dark:text-white">
153
169
{{ i "ban" "w-4 h-4" }}
154
170
<span class="font-medium">closed without merging</span
155
171
>
156
172
</div>
157
173
</div>
158
174
{{ else if .Pull.State.IsMerged }}
159
-
<div class="bg-purple-50 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
160
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
161
-
<div class="flex items-center gap-2 text-purple-500">
175
+
<div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
176
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
177
+
<div class="flex items-center gap-2 text-purple-500 dark:text-purple-300">
162
178
{{ i "git-merge" "w-4 h-4" }}
163
179
<span class="font-medium">pull request successfully merged</span
164
180
>
165
181
</div>
166
182
</div>
167
183
{{ else if and .MergeCheck .MergeCheck.Error }}
168
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
169
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
170
-
<div class="flex items-center gap-2 text-red-500">
184
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
185
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
186
+
<div class="flex items-center gap-2 text-red-500 dark:text-red-300">
171
187
{{ i "triangle-alert" "w-4 h-4" }}
172
188
<span class="font-medium">{{ .MergeCheck.Error }}</span>
173
189
</div>
174
190
</div>
175
191
{{ else if and .MergeCheck .MergeCheck.IsConflicted }}
176
-
<div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
177
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
178
-
<div class="flex items-center gap-2 text-red-500">
179
-
{{ i "triangle-alert" "w-4 h-4" }}
180
-
<span class="font-medium">merge conflicts detected</span>
181
-
<ul class="text-sm space-y-1">
192
+
<div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
193
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
194
+
<div class="flex flex-col gap-2 text-red-500 dark:text-red-300">
195
+
<div class="flex items-center gap-2">
196
+
{{ i "triangle-alert" "w-4 h-4" }}
197
+
<span class="font-medium">merge conflicts detected</span>
198
+
</div>
199
+
<ul class="space-y-1">
182
200
{{ range .MergeCheck.Conflicts }}
183
201
{{ if .Filename }}
184
202
<li class="flex items-center">
185
-
{{ i "file-warning" "w-3 h-3 mr-1.5 text-red-500" }}
203
+
{{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }}
186
204
<span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span>
187
205
</li>
188
206
{{ end }}
···
191
209
</div>
192
210
</div>
193
211
{{ else if .MergeCheck }}
194
-
<div class="bg-green-50 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
195
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div>
196
-
<div class="flex items-center gap-2 text-green-500">
212
+
<div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
213
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
214
+
<div class="flex items-center gap-2 text-green-500 dark:text-green-300">
197
215
{{ i "circle-check-big" "w-4 h-4" }}
198
216
<span class="font-medium">no conflicts, ready to merge</span>
199
217
</div>
200
218
</div>
201
219
{{ end }}
202
220
{{ end }}
221
+
222
+
{{ define "resubmitStatus" }}
223
+
{{ if .ResubmitCheck.Yes }}
224
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit">
225
+
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
226
+
<div class="flex items-center gap-2 text-amber-500 dark:text-amber-300">
227
+
{{ i "triangle-alert" "w-4 h-4" }}
228
+
<span class="font-medium">this branch has been updated, consider resubmitting</span>
229
+
</div>
230
+
</div>
231
+
{{ end }}
232
+
{{ end }}
+30
-15
appview/pages/templates/repo/pulls/pulls.html
+30
-15
appview/pages/templates/repo/pulls/pulls.html
···
2
2
3
3
{{ define "repoContent" }}
4
4
<div class="flex justify-between items-center">
5
-
<p>
5
+
<p class="dark:text-white">
6
6
filtering
7
7
<select
8
-
class="border px-1 bg-white border-gray-200"
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 }}>
···
24
24
href="/{{ .RepoInfo.FullName }}/pulls/new"
25
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>
···
34
34
{{ define "repoAfter" }}
35
35
<div class="flex flex-col gap-2 mt-2">
36
36
{{ range .Pulls }}
37
-
<div class="rounded drop-shadow-sm bg-white px-6 py-4">
37
+
<div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 px-6 py-4">
38
38
<div class="pb-2">
39
-
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}">
39
+
<a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white">
40
40
{{ .Title }}
41
-
<span class="text-gray-500">#{{ .PullId }}</span>
41
+
<span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span>
42
42
</a>
43
43
</div>
44
-
<p class="text-sm text-gray-500">
45
-
{{ $bgColor := "bg-gray-800" }}
44
+
<p class="text-sm text-gray-500 dark:text-gray-400">
45
+
{{ $owner := index $.DidHandleMap .OwnerDid }}
46
+
{{ $bgColor := "bg-gray-800 dark:bg-gray-700" }}
46
47
{{ $icon := "ban" }}
47
48
48
49
{{ if .State.IsOpen }}
49
-
{{ $bgColor = "bg-green-600" }}
50
+
{{ $bgColor = "bg-green-600 dark:bg-green-700" }}
50
51
{{ $icon = "git-pull-request" }}
51
52
{{ else if .State.IsMerged }}
52
-
{{ $bgColor = "bg-purple-600" }}
53
+
{{ $bgColor = "bg-purple-600 dark:bg-purple-700" }}
53
54
{{ $icon = "git-merge" }}
54
55
{{ end }}
55
56
···
62
63
</span>
63
64
64
65
<span>
65
-
{{ $owner := index $.DidHandleMap .OwnerDid }}
66
-
<a href="/{{ $owner }}">{{ $owner }}</a>
66
+
<a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a>
67
67
</span>
68
68
69
69
<span class="before:content-['ยท']">
···
73
73
</span>
74
74
75
75
<span class="before:content-['ยท']">
76
-
targeting branch
77
-
<span class="text-xs rounded bg-gray-100 text-black 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
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 }}
81
96
</p>
82
97
</div>
83
98
{{ end }}
+52
-8
appview/pages/templates/repo/settings.html
+52
-8
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">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 }}
7
9
<div id="collaborator" class="mb-2">
8
10
<a
9
11
href="/{{ didOrHandle .Did .Handle }}"
10
-
class="no-underline hover:underline text-black"
12
+
class="no-underline hover:underline text-black dark:text-white"
11
13
>
12
14
{{ didOrHandle .Did .Handle }}
13
15
</a>
14
16
<div>
15
-
<span class="text-sm text-gray-500">
17
+
<span class="text-sm text-gray-500 dark:text-gray-400">
16
18
{{ .Role }}
17
19
</span>
18
20
</div>
···
20
22
{{ end }}
21
23
</div>
22
24
23
-
{{ if .IsCollaboratorInviteAllowed }}
24
-
<h3>add collaborator</h3>
25
+
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
25
26
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
26
-
<label for="collaborator">did or handle:</label>
27
-
<input type="text" id="collaborator" name="collaborator" required />
28
-
<button class="btn my-2" 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 }}
+24
-22
appview/pages/templates/repo/tree.html
+24
-22
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 text-base">
21
-
<div class="flex justify-between">
22
-
<div id="breadcrumbs">
20
+
<div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
21
+
<div class="flex flex-col md:flex-row md:justify-between gap-2">
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 {{ $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
-
<div id="dir-info">
28
-
<span class="text-gray-500 text-xs">
29
-
{{ $stats := .TreeStats }}
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
+
{{ $stats := .TreeStats }}
30
29
31
-
{{ if eq $stats.NumFolders 1 }}
32
-
{{ $stats.NumFolders }} folder
33
-
<span class="px-1 select-none">ยท</span>
34
-
{{ else if gt $stats.NumFolders 1 }}
35
-
{{ $stats.NumFolders }} folders
36
-
<span class="px-1 select-none">ยท</span>
37
-
{{ end }}
30
+
<span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span>
31
+
{{ if eq $stats.NumFolders 1 }}
32
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
33
+
<span>{{ $stats.NumFolders }} folder</span>
34
+
{{ else if gt $stats.NumFolders 1 }}
35
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
36
+
<span>{{ $stats.NumFolders }} folders</span>
37
+
{{ end }}
38
38
39
-
{{ if eq $stats.NumFiles 1 }}
40
-
{{ $stats.NumFiles }} file
41
-
{{ else if gt $stats.NumFiles 1 }}
42
-
{{ $stats.NumFiles }} files
43
-
{{ end }}
44
-
</span>
39
+
{{ if eq $stats.NumFiles 1 }}
40
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
41
+
<span>{{ $stats.NumFiles }} file</span>
42
+
{{ else if gt $stats.NumFiles 1 }}
43
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
44
+
<span>{{ $stats.NumFiles }} files</span>
45
+
{{ end }}
46
+
45
47
</div>
46
48
</div>
47
49
</div>
···
55
57
{{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }}
56
58
</div>
57
59
</a>
58
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
60
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
59
61
</div>
60
62
</div>
61
63
{{ end }}
···
70
72
{{ i "file" "w-3 h-3" }}{{ .Name }}
71
73
</div>
72
74
</a>
73
-
<time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time>
75
+
<time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time>
74
76
</div>
75
77
</div>
76
78
{{ end }}
+33
-33
appview/pages/templates/settings.html
+33
-33
appview/pages/templates/settings.html
···
2
2
3
3
{{ define "content" }}
4
4
<div class="p-6">
5
-
<p class="text-xl font-bold">Settings</p>
5
+
<p class="text-xl font-bold dark:text-white">Settings</p>
6
6
</div>
7
7
<div class="flex flex-col">
8
8
{{ block "profile" . }} {{ end }}
···
12
12
{{ end }}
13
13
14
14
{{ define "profile" }}
15
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">profile</h2>
16
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
-
<dl class="grid grid-cols-[auto_1fr] gap-x-4">
15
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2>
16
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
17
+
<dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200">
18
18
{{ if .LoggedInUser.Handle }}
19
19
<dt class="font-bold">handle</dt>
20
20
<dd>@{{ .LoggedInUser.Handle }}</dd>
···
28
28
{{ end }}
29
29
30
30
{{ define "keys" }}
31
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2>
32
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
-
<p class="mb-8">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
31
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2>
32
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
33
+
<p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p>
34
34
<div id="key-list" class="flex flex-col gap-6 mb-8">
35
35
{{ range $index, $key := .PubKeys }}
36
36
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
37
37
<div class="flex flex-col gap-1">
38
38
<div class="inline-flex items-center gap-4">
39
-
{{ i "key" "w-3 h-3" }}
40
-
<p class="font-bold">{{ .Name }}</p>
39
+
{{ i "key" "w-3 h-3 dark:text-gray-300" }}
40
+
<p class="font-bold dark:text-white">{{ .Name }}</p>
41
41
</div>
42
-
<p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p>
42
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p>
43
43
<div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full">
44
-
<code class="text-sm text-gray-500">{{ .Key }}</code>
44
+
<code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code>
45
45
</div>
46
46
</div>
47
47
<button
48
-
class="btn text-red-500 hover:text-red-700"
48
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2"
49
49
title="Delete key"
50
50
hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}"
51
51
hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?">
···
66
66
name="name"
67
67
placeholder="key name"
68
68
required
69
-
class="w-full"/>
69
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
70
70
71
71
<input
72
72
id="key"
73
73
name="key"
74
74
placeholder="ssh-rsa AAAAAA..."
75
75
required
76
-
class="w-full"/>
76
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
77
77
78
-
<button class="btn" type="submit">add key</button>
78
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button>
79
79
80
-
<div id="settings-keys" class="error"></div>
80
+
<div id="settings-keys" class="error dark:text-red-400"></div>
81
81
</form>
82
82
</section>
83
83
{{ end }}
84
84
85
85
{{ define "emails" }}
86
-
<h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2>
87
-
<section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
88
-
<p class="mb-8">Commits authored using emails listed here will be associated with your Tangled profile.</p>
86
+
<h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2>
87
+
<section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit">
88
+
<p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p>
89
89
<div id="email-list" class="flex flex-col gap-6 mb-8">
90
90
{{ range $index, $email := .Emails }}
91
91
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
92
92
<div class="flex flex-col gap-2">
93
93
<div class="inline-flex items-center gap-4">
94
-
{{ i "mail" "w-3 h-3" }}
95
-
<p class="font-bold">{{ .Address }}</p>
94
+
{{ i "mail" "w-3 h-3 dark:text-gray-300" }}
95
+
<p class="font-bold dark:text-white">{{ .Address }}</p>
96
96
<div class="inline-flex items-center gap-1">
97
97
{{ if .Verified }}
98
-
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span>
98
+
<span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span>
99
99
{{ else }}
100
-
<span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span>
100
+
<span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span>
101
101
{{ end }}
102
102
{{ if .Primary }}
103
-
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span>
103
+
<span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span>
104
104
{{ end }}
105
105
</div>
106
106
</div>
107
-
<p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p>
107
+
<p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p>
108
108
</div>
109
109
<div class="flex gap-2 items-center">
110
110
{{ if not .Verified }}
111
111
<button
112
-
class="btn flex gap-2"
112
+
class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
113
113
hx-post="/settings/emails/verify/resend"
114
114
hx-swap="none"
115
115
href="#"
···
120
120
{{ end }}
121
121
{{ if and (not .Primary) .Verified }}
122
122
<a
123
-
class="text-sm"
123
+
class="text-sm dark:text-blue-400 dark:hover:text-blue-300"
124
124
hx-post="/settings/emails/primary"
125
125
hx-swap="none"
126
126
href="#"
···
132
132
<form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?">
133
133
<input type="hidden" name="email" value="{{ .Address }}">
134
134
<button
135
-
class="btn text-red-500 hover:text-red-700"
135
+
class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
136
136
title="Delete email"
137
137
type="submit">
138
138
{{ i "trash-2" "w-5 h-5" }}
···
155
155
name="email"
156
156
placeholder="your@email.com"
157
157
required
158
-
class="w-full"/>
158
+
class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/>
159
159
160
-
<button class="btn" type="submit">add email</button>
160
+
<button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button>
161
161
162
-
<div id="settings-emails-error" class="error"></div>
163
-
<div id="settings-emails-success" class="success"></div>
162
+
<div id="settings-emails-error" class="error dark:text-red-400"></div>
163
+
<div id="settings-emails-success" class="success dark:text-green-400"></div>
164
164
165
165
</form>
166
166
</section>
167
-
{{ end }}
167
+
{{ end }}
+22
-14
appview/pages/templates/timeline.html
+22
-14
appview/pages/templates/timeline.html
···
17
17
{{ end }}
18
18
19
19
{{ define "hero" }}
20
-
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white text-black py-4 px-10">
20
+
<div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white dark:bg-gray-800 text-black dark:text-white py-4 px-10">
21
21
<div class="font-bold italic text-4xl mb-4">
22
22
tangled
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">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 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>
···
32
32
{{ define "timeline" }}
33
33
<div>
34
34
<div class="p-6">
35
-
<p class="text-xl font-bold">Timeline</p>
35
+
<p class="text-xl font-bold dark:text-white">Timeline</p>
36
36
</div>
37
37
38
38
<div class="flex flex-col gap-3 relative">
39
-
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div>
39
+
<div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div>
40
40
{{ range .Timeline }}
41
-
<div class="px-6 py-2 bg-white rounded drop-shadow-sm w-fit">
41
+
<div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit">
42
42
{{ if .Repo }}
43
43
{{ $userHandle := index $.DidHandleMap .Repo.Did }}
44
44
<div class="flex items-center">
45
-
<p class="text-gray-600">
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>
49
-
<time class="text-gray-700 text-xs">{{ .Repo.Created | timeFmt }}</time>
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 }}
58
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
50
59
</p>
51
60
</div>
52
61
{{ else if .Follow }}
53
62
{{ $userHandle := index $.DidHandleMap .Follow.UserDid }}
54
63
{{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }}
55
64
<div class="flex items-center">
56
-
<p class="text-gray-600">
65
+
<p class="text-gray-600 dark:text-gray-300">
57
66
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
58
67
followed
59
68
<a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a>
60
-
<time class="text-gray-700 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
69
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time>
61
70
</p>
62
71
</div>
63
72
{{ else if .Star }}
64
73
{{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }}
65
74
{{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }}
66
75
<div class="flex items-center">
67
-
<p class="text-gray-600">
76
+
<p class="text-gray-600 dark:text-gray-300">
68
77
<a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a>
69
78
starred
70
79
<a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a>
71
-
<time class="text-gray-700 text-xs">{{ .Star.Created | timeFmt }}</time>
80
+
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time>
72
81
</p>
73
82
</div>
74
83
{{ end }}
···
77
86
</div>
78
87
</div>
79
88
{{ end }}
80
-
+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 }}
+14
-6
appview/pages/templates/user/login.html
+14
-6
appview/pages/templates/user/login.html
···
1
1
{{ define "user/login" }}
2
2
<!doctype html>
3
-
<html lang="en">
3
+
<html lang="en" class="dark:bg-gray-900">
4
4
<head>
5
5
<meta charset="UTF-8" />
6
6
<meta
···
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">
15
-
<main class="max-w-64">
16
-
<h1 class="text-center text-2xl font-semibold italic">
15
+
<main class="max-w-7xl px-6 -mt-4">
16
+
<h1 class="text-center text-2xl font-semibold italic dark:text-white">
17
17
tangled
18
18
</h1>
19
-
<h2 class="text-center text-xl italic">
19
+
<h2 class="text-center text-xl italic dark:text-white">
20
20
tightly-knit social coding.
21
21
</h2>
22
22
<form
···
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>
+240
-27
appview/pages/templates/user/profile.html
+240
-27
appview/pages/templates/user/profile.html
···
1
1
{{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }}
2
2
3
3
{{ define "content" }}
4
-
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
5
-
<div class="md:col-span-1">
6
-
{{ block "profileCard" . }}{{ end }}
4
+
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
5
+
<div class="md:col-span-1 order-1 md:order-1">
6
+
{{ block "profileCard" . }}{{ end }}
7
+
</div>
8
+
<div class="md:col-span-2 order-2 md:order-2">
9
+
{{ block "ownRepos" . }}{{ end }}
10
+
{{ block "collaboratingRepos" . }}{{ end }}
11
+
</div>
12
+
<div class="md:col-span-2 order-3 md:order-3">
13
+
{{ block "profileTimeline" . }}{{ end }}
14
+
</div>
15
+
</div>
16
+
{{ end }}
17
+
18
+
{{ define "profileTimeline" }}
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
38
+
</div>
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 }}
44
+
</div>
45
+
{{ end }}
7
46
</div>
8
47
9
-
<div class="md:col-span-3">
10
-
{{ block "ownRepos" . }}{{ end }}
11
-
{{ block "collaboratingRepos" . }}{{ end }}
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>
81
+
</div>
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>
133
+
{{ end }}
134
+
<div class="flex-none min-w-8 text-right">
135
+
<span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span>
136
+
</div>
137
+
<div class="break-words max-w-full">
138
+
<a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline">
139
+
{{ .Title -}}
140
+
</a>
141
+
on
142
+
<a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap">
143
+
{{$repoUrl}}
144
+
</a>
145
+
</div>
146
+
</div>
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-black dark:text-white bg-gray-50 dark:bg-gray-700 ">
183
+
{{$stats.Closed}} closed
184
+
</span>
185
+
{{ end }}
186
+
12
187
</div>
13
-
</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>
211
+
</div>
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>
220
+
</div>
221
+
</div>
222
+
{{ end }}
223
+
</div>
224
+
</details>
225
+
{{ end }}
14
226
{{ end }}
15
227
16
228
{{ define "profileCard" }}
17
-
<div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit">
229
+
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
18
230
<div class="flex justify-center items-center">
19
231
{{ if .AvatarUri }}
20
-
<img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" />
232
+
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
21
233
{{ end }}
22
234
</div>
23
-
<p class="text-xl font-bold text-center">
24
-
{{ 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 }}
25
240
</p>
26
-
<div class="text-sm text-center">
241
+
<div class="text-sm text-center dark:text-gray-300">
27
242
<span>{{ .ProfileStats.Followers }} followers</span>
28
243
<div
29
244
class="inline-block px-1 select-none after:content-['ยท']"
···
32
247
</div>
33
248
34
249
{{ if ne .FollowStatus.String "IsSelf" }}
35
-
{{ template "fragments/follow" . }}
250
+
{{ template "user/fragments/follow" . }}
36
251
{{ end }}
37
252
</div>
38
253
{{ end }}
39
254
40
255
{{ define "ownRepos" }}
41
-
<p class="text-sm font-bold py-2 px-6">REPOS</p>
42
-
<div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
256
+
<p class="text-sm font-bold py-2 px-6 dark:text-white">REPOS</p>
257
+
<div id="repos" class="grid grid-cols-1 gap-4 mb-6">
43
258
{{ range .Repos }}
44
259
<div
45
260
id="repo-card"
46
-
class="py-4 px-6 drop-shadow-sm rounded bg-white"
261
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"
47
262
>
48
-
<div id="repo-card-name" class="font-medium">
263
+
<div id="repo-card-name" class="font-medium dark:text-white">
49
264
<a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}"
50
265
>{{ .Name }}</a
51
266
>
52
267
</div>
53
268
{{ if .Description }}
54
-
<div class="text-gray-600 text-sm">
269
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
55
270
{{ .Description }}
56
271
</div>
57
272
{{ end }}
···
68
283
</div>
69
284
</div>
70
285
{{ else }}
71
-
<p class="px-6">This user does not have any repos yet.</p>
286
+
<p class="px-6 dark:text-white">This user does not have any repos yet.</p>
72
287
{{ end }}
73
288
</div>
74
-
{{ end }}
75
289
76
-
{{ define "collaboratingRepos" }}
77
-
<p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p>
78
-
<div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
290
+
<p class="text-sm font-bold py-2 px-6 dark:text-white">COLLABORATING ON</p>
291
+
<div id="collaborating" class="grid grid-cols-1 gap-4 mb-6">
79
292
{{ range .CollaboratingRepos }}
80
293
<div
81
294
id="repo-card"
82
-
class="py-4 px-6 drop-shadow-sm rounded bg-white flex flex-col"
295
+
class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"
83
296
>
84
-
<div id="repo-card-name" class="font-medium">
297
+
<div id="repo-card-name" class="font-medium dark:text-white">
85
298
<a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}">
86
299
{{ index $.DidHandleMap .Did }}/{{ .Name }}
87
300
</a>
88
301
</div>
89
302
{{ if .Description }}
90
-
<div class="text-gray-600 text-sm">
303
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
91
304
{{ .Description }}
92
305
</div>
93
306
{{ end }}
···
102
315
</div>
103
316
</div>
104
317
{{ else }}
105
-
<p class="px-6">This user is not collaborating.</p>
318
+
<p class="px-6 dark:text-white">This user is not collaborating.</p>
106
319
{{ end }}
107
320
</div>
108
321
{{ end }}
+1
-1
appview/state/jetstream.go
+1
-1
appview/state/jetstream.go
+102
appview/state/profile.go
+102
appview/state/profile.go
···
1
+
package state
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
"net/http"
7
+
8
+
"github.com/go-chi/chi/v5"
9
+
"tangled.sh/tangled.sh/core/appview/db"
10
+
"tangled.sh/tangled.sh/core/appview/pages"
11
+
)
12
+
13
+
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
14
+
didOrHandle := chi.URLParam(r, "user")
15
+
if didOrHandle == "" {
16
+
http.Error(w, "Bad request", http.StatusBadRequest)
17
+
return
18
+
}
19
+
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)
24
+
return
25
+
}
26
+
27
+
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
28
+
if err != nil {
29
+
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
30
+
}
31
+
32
+
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
33
+
if err != nil {
34
+
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
35
+
}
36
+
37
+
timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String())
38
+
if err != nil {
39
+
log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err)
40
+
}
41
+
42
+
var didsToResolve []string
43
+
for _, r := range collaboratingRepos {
44
+
didsToResolve = append(didsToResolve, r.Did)
45
+
}
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
+
}
59
+
}
60
+
61
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
62
+
didHandleMap := make(map[string]string)
63
+
for _, identity := range resolvedIds {
64
+
if !identity.Handle.IsInvalidHandle() {
65
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
66
+
} else {
67
+
didHandleMap[identity.DID.String()] = identity.DID.String()
68
+
}
69
+
}
70
+
71
+
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
72
+
if err != nil {
73
+
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
74
+
}
75
+
76
+
loggedInUser := s.auth.GetUser(r)
77
+
followStatus := db.IsNotFollowing
78
+
if loggedInUser != nil {
79
+
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
80
+
}
81
+
82
+
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
83
+
if err != nil {
84
+
log.Println("failed to fetch bsky avatar", err)
85
+
}
86
+
87
+
s.pages.ProfilePage(w, pages.ProfilePageParams{
88
+
LoggedInUser: loggedInUser,
89
+
UserDid: ident.DID.String(),
90
+
UserHandle: ident.Handle.String(),
91
+
Repos: repos,
92
+
CollaboratingRepos: collaboratingRepos,
93
+
ProfileStats: pages.ProfileStats{
94
+
Followers: followers,
95
+
Following: following,
96
+
},
97
+
FollowStatus: db.FollowStatus(followStatus),
98
+
DidHandleMap: didHandleMap,
99
+
AvatarUri: profileAvatarUri,
100
+
ProfileTimeline: timeline,
101
+
})
102
+
}
+884
-122
appview/state/pull.go
+884
-122
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
13
"strings"
11
14
"time"
12
15
13
16
"github.com/go-chi/chi/v5"
14
17
"tangled.sh/tangled.sh/core/api/tangled"
18
+
"tangled.sh/tangled.sh/core/appview/auth"
15
19
"tangled.sh/tangled.sh/core/appview/db"
16
20
"tangled.sh/tangled.sh/core/appview/pages"
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"
21
26
)
22
27
···
50
55
}
51
56
52
57
mergeCheckResponse := s.mergeCheck(f, pull)
58
+
resubmitResult := pages.Unknown
59
+
if user.Did == pull.OwnerDid {
60
+
resubmitResult = s.resubmitCheck(f, pull)
61
+
}
53
62
54
63
s.pages.PullActionsFragment(w, pages.PullActionsParams{
55
-
LoggedInUser: user,
56
-
RepoInfo: f.RepoInfo(s, user),
57
-
Pull: pull,
58
-
RoundNumber: roundNumber,
59
-
MergeCheck: mergeCheckResponse,
64
+
LoggedInUser: user,
65
+
RepoInfo: f.RepoInfo(s, user),
66
+
Pull: pull,
67
+
RoundNumber: roundNumber,
68
+
MergeCheck: mergeCheckResponse,
69
+
ResubmitCheck: resubmitResult,
60
70
})
61
71
return
62
72
}
···
105
115
}
106
116
107
117
mergeCheckResponse := s.mergeCheck(f, pull)
118
+
resubmitResult := pages.Unknown
119
+
if user != nil && user.Did == pull.OwnerDid {
120
+
resubmitResult = s.resubmitCheck(f, pull)
121
+
}
122
+
123
+
var pullSourceRepo *db.Repo
124
+
if pull.PullSource != nil {
125
+
if pull.PullSource.RepoAt != nil {
126
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
127
+
if err != nil {
128
+
log.Printf("failed to get repo by at uri: %v", err)
129
+
return
130
+
}
131
+
}
132
+
}
108
133
109
134
s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
110
-
LoggedInUser: user,
111
-
RepoInfo: f.RepoInfo(s, user),
112
-
DidHandleMap: didHandleMap,
113
-
Pull: *pull,
114
-
MergeCheck: mergeCheckResponse,
135
+
LoggedInUser: user,
136
+
RepoInfo: f.RepoInfo(s, user),
137
+
DidHandleMap: didHandleMap,
138
+
Pull: pull,
139
+
PullSourceRepo: pullSourceRepo,
140
+
MergeCheck: mergeCheckResponse,
141
+
ResubmitCheck: resubmitResult,
115
142
})
116
143
}
117
144
···
175
202
return mergeCheckResponse
176
203
}
177
204
205
+
func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
206
+
if pull.State == db.PullMerged || pull.PullSource == nil {
207
+
return pages.Unknown
208
+
}
209
+
210
+
var knot, ownerDid, repoName string
211
+
212
+
if pull.PullSource.RepoAt != nil {
213
+
// fork-based pulls
214
+
sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
215
+
if err != nil {
216
+
log.Println("failed to get source repo", err)
217
+
return pages.Unknown
218
+
}
219
+
220
+
knot = sourceRepo.Knot
221
+
ownerDid = sourceRepo.Did
222
+
repoName = sourceRepo.Name
223
+
} else {
224
+
// pulls within the same repo
225
+
knot = f.Knot
226
+
ownerDid = f.OwnerDid()
227
+
repoName = f.RepoName
228
+
}
229
+
230
+
us, err := NewUnsignedClient(knot, s.config.Dev)
231
+
if err != nil {
232
+
log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
233
+
return pages.Unknown
234
+
}
235
+
236
+
resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
237
+
if err != nil {
238
+
log.Println("failed to reach knotserver", err)
239
+
return pages.Unknown
240
+
}
241
+
242
+
body, err := io.ReadAll(resp.Body)
243
+
if err != nil {
244
+
log.Printf("error reading response body: %v", err)
245
+
return pages.Unknown
246
+
}
247
+
defer resp.Body.Close()
248
+
249
+
var result types.RepoBranchResponse
250
+
if err := json.Unmarshal(body, &result); err != nil {
251
+
log.Println("failed to parse response:", err)
252
+
return pages.Unknown
253
+
}
254
+
255
+
latestSubmission := pull.Submissions[pull.LastRoundNumber()]
256
+
if latestSubmission.SourceRev != result.Branch.Hash {
257
+
return pages.ShouldResubmit
258
+
}
259
+
260
+
return pages.ShouldNotResubmit
261
+
}
262
+
178
263
func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
179
264
user := s.auth.GetUser(r)
180
265
f, err := fullyResolvedRepo(r)
···
221
306
222
307
}
223
308
309
+
func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
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 edit patch. Try again later.")
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
+
identsToResolve := []string{pull.OwnerDid}
326
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
327
+
didHandleMap := make(map[string]string)
328
+
for _, identity := range resolvedIds {
329
+
if !identity.Handle.IsInvalidHandle() {
330
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
331
+
} else {
332
+
didHandleMap[identity.DID.String()] = identity.DID.String()
333
+
}
334
+
}
335
+
336
+
w.Header().Set("Content-Type", "text/plain")
337
+
w.Write([]byte(pull.Submissions[roundIdInt].Patch))
338
+
}
339
+
224
340
func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
225
341
user := s.auth.GetUser(r)
226
342
params := r.URL.Query()
···
246
362
return
247
363
}
248
364
365
+
for _, p := range pulls {
366
+
var pullSourceRepo *db.Repo
367
+
if p.PullSource != nil {
368
+
if p.PullSource.RepoAt != nil {
369
+
pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
370
+
if err != nil {
371
+
log.Printf("failed to get repo by at uri: %v", err)
372
+
continue
373
+
} else {
374
+
p.PullSource.Repo = pullSourceRepo
375
+
}
376
+
}
377
+
}
378
+
}
379
+
249
380
identsToResolve := make([]string, len(pulls))
250
381
for i, pull := range pulls {
251
382
identsToResolve[i] = pull.OwnerDid
···
344
475
},
345
476
},
346
477
})
347
-
log.Println(atResp.Uri)
348
478
if err != nil {
349
479
log.Println("failed to create pull comment", err)
350
480
s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···
423
553
title := r.FormValue("title")
424
554
body := r.FormValue("body")
425
555
targetBranch := r.FormValue("targetBranch")
556
+
fromFork := r.FormValue("fork")
557
+
sourceBranch := r.FormValue("sourceBranch")
426
558
patch := r.FormValue("patch")
427
559
428
-
if title == "" || body == "" || patch == "" || targetBranch == "" {
429
-
s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
560
+
// Validate required fields for all PR types
561
+
if title == "" || body == "" || targetBranch == "" {
562
+
s.pages.Notice(w, "pull", "Title, body and target branch are required.")
430
563
return
431
564
}
432
565
433
-
// Validate patch format
434
-
if !isPatchValid(patch) {
435
-
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
566
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
567
+
if err != nil {
568
+
log.Println("failed to create unsigned client to %s: %v", f.Knot, err)
569
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
436
570
return
437
571
}
438
572
439
-
tx, err := s.db.BeginTx(r.Context(), nil)
573
+
caps, err := us.Capabilities()
440
574
if err != nil {
441
-
log.Println("failed to start tx")
442
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
575
+
log.Println("error fetching knot caps", f.Knot, err)
576
+
s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
443
577
return
444
578
}
445
-
defer tx.Rollback()
446
579
447
-
rkey := s.TID()
448
-
initialSubmission := db.PullSubmission{
449
-
Patch: patch,
450
-
}
451
-
err = db.NewPull(tx, &db.Pull{
452
-
Title: title,
453
-
Body: body,
454
-
TargetBranch: targetBranch,
455
-
OwnerDid: user.Did,
456
-
RepoAt: f.RepoAt,
457
-
Rkey: rkey,
458
-
Submissions: []*db.PullSubmission{
459
-
&initialSubmission,
460
-
},
461
-
})
462
-
if err != nil {
463
-
log.Println("failed to create pull request", err)
464
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
580
+
// Determine PR type based on input parameters
581
+
isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
582
+
isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
583
+
isForkBased := fromFork != "" && sourceBranch != ""
584
+
isPatchBased := patch != "" && !isBranchBased && !isForkBased
585
+
586
+
// Validate we have at least one valid PR creation method
587
+
if !isBranchBased && !isPatchBased && !isForkBased {
588
+
s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
465
589
return
466
590
}
467
-
client, _ := s.auth.AuthorizedClient(r)
468
-
pullId, err := db.NextPullId(s.db, f.RepoAt)
469
-
if err != nil {
470
-
log.Println("failed to get pull id", err)
471
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
591
+
592
+
// Can't mix branch-based and patch-based approaches
593
+
if isBranchBased && patch != "" {
594
+
s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
472
595
return
473
596
}
474
597
475
-
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
476
-
Collection: tangled.RepoPullNSID,
477
-
Repo: user.Did,
478
-
Rkey: rkey,
479
-
Record: &lexutil.LexiconTypeDecoder{
480
-
Val: &tangled.RepoPull{
481
-
Title: title,
482
-
PullId: int64(pullId),
483
-
TargetRepo: string(f.RepoAt),
484
-
TargetBranch: targetBranch,
485
-
Patch: patch,
486
-
},
598
+
// Handle the PR creation based on the type
599
+
if isBranchBased {
600
+
if !caps.PullRequests.BranchSubmissions {
601
+
s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
602
+
return
603
+
}
604
+
s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
605
+
} else if isForkBased {
606
+
if !caps.PullRequests.ForkSubmissions {
607
+
s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
608
+
return
609
+
}
610
+
s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
611
+
} else if isPatchBased {
612
+
if !caps.PullRequests.PatchSubmissions {
613
+
s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
614
+
return
615
+
}
616
+
s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
617
+
}
618
+
return
619
+
}
620
+
}
621
+
622
+
func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
623
+
pullSource := &db.PullSource{
624
+
Branch: sourceBranch,
625
+
}
626
+
recordPullSource := &tangled.RepoPull_Source{
627
+
Branch: sourceBranch,
628
+
}
629
+
630
+
// Generate a patch using /compare
631
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
632
+
if err != nil {
633
+
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
634
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
635
+
return
636
+
}
637
+
638
+
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
639
+
if err != nil {
640
+
log.Println("failed to compare", err)
641
+
s.pages.Notice(w, "pull", err.Error())
642
+
return
643
+
}
644
+
645
+
sourceRev := diffTreeResponse.DiffTree.Rev2
646
+
patch := diffTreeResponse.DiffTree.Patch
647
+
648
+
if !isPatchValid(patch) {
649
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
650
+
return
651
+
}
652
+
653
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
654
+
}
655
+
656
+
func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
657
+
if !isPatchValid(patch) {
658
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
659
+
return
660
+
}
661
+
662
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
663
+
}
664
+
665
+
func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
666
+
fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
667
+
if errors.Is(err, sql.ErrNoRows) {
668
+
s.pages.Notice(w, "pull", "No such fork.")
669
+
return
670
+
} else if err != nil {
671
+
log.Println("failed to fetch fork:", err)
672
+
s.pages.Notice(w, "pull", "Failed to fetch fork.")
673
+
return
674
+
}
675
+
676
+
secret, err := db.GetRegistrationKey(s.db, fork.Knot)
677
+
if err != nil {
678
+
log.Println("failed to fetch registration key:", err)
679
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
680
+
return
681
+
}
682
+
683
+
sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
684
+
if err != nil {
685
+
log.Println("failed to create signed client:", err)
686
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
687
+
return
688
+
}
689
+
690
+
us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
691
+
if err != nil {
692
+
log.Println("failed to create unsigned client:", err)
693
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
694
+
return
695
+
}
696
+
697
+
resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
698
+
if err != nil {
699
+
log.Println("failed to create hidden ref:", err, resp.StatusCode)
700
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
701
+
return
702
+
}
703
+
704
+
switch resp.StatusCode {
705
+
case 404:
706
+
case 400:
707
+
s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
708
+
return
709
+
}
710
+
711
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
712
+
// We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
713
+
// the targetBranch on the target repository. This code is a bit confusing, but here's an example:
714
+
// hiddenRef: hidden/feature-1/main (on repo-fork)
715
+
// targetBranch: main (on repo-1)
716
+
// sourceBranch: feature-1 (on repo-fork)
717
+
diffTreeResponse, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
718
+
if err != nil {
719
+
log.Println("failed to compare across branches", err)
720
+
s.pages.Notice(w, "pull", err.Error())
721
+
return
722
+
}
723
+
724
+
sourceRev := diffTreeResponse.DiffTree.Rev2
725
+
patch := diffTreeResponse.DiffTree.Patch
726
+
727
+
if !isPatchValid(patch) {
728
+
s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
729
+
return
730
+
}
731
+
732
+
forkAtUri, err := syntax.ParseATURI(fork.AtUri)
733
+
if err != nil {
734
+
log.Println("failed to parse fork AT URI", err)
735
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
736
+
return
737
+
}
738
+
739
+
s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
740
+
Branch: sourceBranch,
741
+
RepoAt: &forkAtUri,
742
+
}, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
743
+
}
744
+
745
+
func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) {
746
+
tx, err := s.db.BeginTx(r.Context(), nil)
747
+
if err != nil {
748
+
log.Println("failed to start tx")
749
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
750
+
return
751
+
}
752
+
defer tx.Rollback()
753
+
754
+
rkey := s.TID()
755
+
initialSubmission := db.PullSubmission{
756
+
Patch: patch,
757
+
SourceRev: sourceRev,
758
+
}
759
+
err = db.NewPull(tx, &db.Pull{
760
+
Title: title,
761
+
Body: body,
762
+
TargetBranch: targetBranch,
763
+
OwnerDid: user.Did,
764
+
RepoAt: f.RepoAt,
765
+
Rkey: rkey,
766
+
Submissions: []*db.PullSubmission{
767
+
&initialSubmission,
768
+
},
769
+
PullSource: pullSource,
770
+
})
771
+
if err != nil {
772
+
log.Println("failed to create pull request", err)
773
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
774
+
return
775
+
}
776
+
client, _ := s.auth.AuthorizedClient(r)
777
+
pullId, err := db.NextPullId(s.db, f.RepoAt)
778
+
if err != nil {
779
+
log.Println("failed to get pull id", err)
780
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
781
+
return
782
+
}
783
+
784
+
atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
785
+
Collection: tangled.RepoPullNSID,
786
+
Repo: user.Did,
787
+
Rkey: rkey,
788
+
Record: &lexutil.LexiconTypeDecoder{
789
+
Val: &tangled.RepoPull{
790
+
Title: title,
791
+
PullId: int64(pullId),
792
+
TargetRepo: string(f.RepoAt),
793
+
TargetBranch: targetBranch,
794
+
Patch: patch,
795
+
Source: recordPullSource,
487
796
},
488
-
})
797
+
},
798
+
})
799
+
800
+
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
801
+
if err != nil {
802
+
log.Println("failed to get pull id", err)
803
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
804
+
return
805
+
}
806
+
807
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
808
+
}
809
+
810
+
func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
811
+
user := s.auth.GetUser(r)
812
+
f, err := fullyResolvedRepo(r)
813
+
if err != nil {
814
+
log.Println("failed to get repo and knot", err)
815
+
return
816
+
}
817
+
818
+
s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
819
+
RepoInfo: f.RepoInfo(s, user),
820
+
})
821
+
}
489
822
490
-
err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
491
-
if err != nil {
492
-
log.Println("failed to get pull id", err)
493
-
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
494
-
return
495
-
}
823
+
func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
824
+
user := s.auth.GetUser(r)
825
+
f, err := fullyResolvedRepo(r)
826
+
if err != nil {
827
+
log.Println("failed to get repo and knot", err)
828
+
return
829
+
}
830
+
831
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
832
+
if err != nil {
833
+
log.Printf("failed to create unsigned client for %s", f.Knot)
834
+
s.pages.Error503(w)
835
+
return
836
+
}
496
837
497
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
838
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
839
+
if err != nil {
840
+
log.Println("failed to reach knotserver", err)
498
841
return
499
842
}
843
+
844
+
body, err := io.ReadAll(resp.Body)
845
+
if err != nil {
846
+
log.Printf("Error reading response body: %v", err)
847
+
return
848
+
}
849
+
850
+
var result types.RepoBranchesResponse
851
+
err = json.Unmarshal(body, &result)
852
+
if err != nil {
853
+
log.Println("failed to parse response:", err)
854
+
return
855
+
}
856
+
857
+
s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
858
+
RepoInfo: f.RepoInfo(s, user),
859
+
Branches: result.Branches,
860
+
})
861
+
}
862
+
863
+
func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
864
+
user := s.auth.GetUser(r)
865
+
f, err := fullyResolvedRepo(r)
866
+
if err != nil {
867
+
log.Println("failed to get repo and knot", err)
868
+
return
869
+
}
870
+
871
+
forks, err := db.GetForksByDid(s.db, user.Did)
872
+
if err != nil {
873
+
log.Println("failed to get forks", err)
874
+
return
875
+
}
876
+
877
+
s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
878
+
RepoInfo: f.RepoInfo(s, user),
879
+
Forks: forks,
880
+
})
881
+
}
882
+
883
+
func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
884
+
user := s.auth.GetUser(r)
885
+
886
+
f, err := fullyResolvedRepo(r)
887
+
if err != nil {
888
+
log.Println("failed to get repo and knot", err)
889
+
return
890
+
}
891
+
892
+
forkVal := r.URL.Query().Get("fork")
893
+
894
+
// fork repo
895
+
repo, err := db.GetRepo(s.db, user.Did, forkVal)
896
+
if err != nil {
897
+
log.Println("failed to get repo", user.Did, forkVal)
898
+
return
899
+
}
900
+
901
+
sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
902
+
if err != nil {
903
+
log.Printf("failed to create unsigned client for %s", repo.Knot)
904
+
s.pages.Error503(w)
905
+
return
906
+
}
907
+
908
+
sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
909
+
if err != nil {
910
+
log.Println("failed to reach knotserver for source branches", err)
911
+
return
912
+
}
913
+
914
+
sourceBody, err := io.ReadAll(sourceResp.Body)
915
+
if err != nil {
916
+
log.Println("failed to read source response body", err)
917
+
return
918
+
}
919
+
defer sourceResp.Body.Close()
920
+
921
+
var sourceResult types.RepoBranchesResponse
922
+
err = json.Unmarshal(sourceBody, &sourceResult)
923
+
if err != nil {
924
+
log.Println("failed to parse source branches response:", err)
925
+
return
926
+
}
927
+
928
+
targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
929
+
if err != nil {
930
+
log.Printf("failed to create unsigned client for target knot %s", f.Knot)
931
+
s.pages.Error503(w)
932
+
return
933
+
}
934
+
935
+
targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
936
+
if err != nil {
937
+
log.Println("failed to reach knotserver for target branches", err)
938
+
return
939
+
}
940
+
941
+
targetBody, err := io.ReadAll(targetResp.Body)
942
+
if err != nil {
943
+
log.Println("failed to read target response body", err)
944
+
return
945
+
}
946
+
defer targetResp.Body.Close()
947
+
948
+
var targetResult types.RepoBranchesResponse
949
+
err = json.Unmarshal(targetBody, &targetResult)
950
+
if err != nil {
951
+
log.Println("failed to parse target branches response:", err)
952
+
return
953
+
}
954
+
955
+
s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
956
+
RepoInfo: f.RepoInfo(s, user),
957
+
SourceBranches: sourceResult.Branches,
958
+
TargetBranches: targetResult.Branches,
959
+
})
500
960
}
501
961
502
962
func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
···
522
982
})
523
983
return
524
984
case http.MethodPost:
525
-
patch := r.FormValue("patch")
526
-
527
-
if patch == "" {
528
-
s.pages.Notice(w, "resubmit-error", "Patch is empty.")
985
+
if pull.IsPatchBased() {
986
+
s.resubmitPatch(w, r)
987
+
return
988
+
} else if pull.IsBranchBased() {
989
+
s.resubmitBranch(w, r)
990
+
return
991
+
} else if pull.IsForkBased() {
992
+
s.resubmitFork(w, r)
529
993
return
530
994
}
995
+
}
996
+
}
531
997
532
-
if patch == pull.LatestPatch() {
533
-
s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
534
-
return
535
-
}
998
+
func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
999
+
user := s.auth.GetUser(r)
536
1000
537
-
// Validate patch format
538
-
if !isPatchValid(patch) {
539
-
s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
540
-
return
541
-
}
1001
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1002
+
if !ok {
1003
+
log.Println("failed to get pull")
1004
+
s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1005
+
return
1006
+
}
1007
+
1008
+
f, err := fullyResolvedRepo(r)
1009
+
if err != nil {
1010
+
log.Println("failed to get repo and knot", err)
1011
+
return
1012
+
}
542
1013
543
-
tx, err := s.db.BeginTx(r.Context(), nil)
544
-
if err != nil {
545
-
log.Println("failed to start tx")
546
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
547
-
return
548
-
}
549
-
defer tx.Rollback()
1014
+
if user.Did != pull.OwnerDid {
1015
+
log.Println("unauthorized user")
1016
+
w.WriteHeader(http.StatusUnauthorized)
1017
+
return
1018
+
}
1019
+
1020
+
patch := r.FormValue("patch")
1021
+
1022
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1023
+
s.pages.Notice(w, "resubmit-error", err.Error())
1024
+
}
1025
+
1026
+
tx, err := s.db.BeginTx(r.Context(), nil)
1027
+
if err != nil {
1028
+
log.Println("failed to start tx")
1029
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1030
+
return
1031
+
}
1032
+
defer tx.Rollback()
1033
+
1034
+
err = db.ResubmitPull(tx, pull, patch, "")
1035
+
if err != nil {
1036
+
log.Println("failed to resubmit pull request", err)
1037
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1038
+
return
1039
+
}
1040
+
client, _ := s.auth.AuthorizedClient(r)
1041
+
1042
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1043
+
if err != nil {
1044
+
// failed to get record
1045
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1046
+
return
1047
+
}
1048
+
1049
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1050
+
Collection: tangled.RepoPullNSID,
1051
+
Repo: user.Did,
1052
+
Rkey: pull.Rkey,
1053
+
SwapRecord: ex.Cid,
1054
+
Record: &lexutil.LexiconTypeDecoder{
1055
+
Val: &tangled.RepoPull{
1056
+
Title: pull.Title,
1057
+
PullId: int64(pull.PullId),
1058
+
TargetRepo: string(f.RepoAt),
1059
+
TargetBranch: pull.TargetBranch,
1060
+
Patch: patch, // new patch
1061
+
},
1062
+
},
1063
+
})
1064
+
if err != nil {
1065
+
log.Println("failed to update record", err)
1066
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1067
+
return
1068
+
}
1069
+
1070
+
if err = tx.Commit(); err != nil {
1071
+
log.Println("failed to commit transaction", err)
1072
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1073
+
return
1074
+
}
1075
+
1076
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1077
+
return
1078
+
}
1079
+
1080
+
func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1081
+
user := s.auth.GetUser(r)
1082
+
1083
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1084
+
if !ok {
1085
+
log.Println("failed to get pull")
1086
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1087
+
return
1088
+
}
1089
+
1090
+
f, err := fullyResolvedRepo(r)
1091
+
if err != nil {
1092
+
log.Println("failed to get repo and knot", err)
1093
+
return
1094
+
}
1095
+
1096
+
if user.Did != pull.OwnerDid {
1097
+
log.Println("unauthorized user")
1098
+
w.WriteHeader(http.StatusUnauthorized)
1099
+
return
1100
+
}
1101
+
1102
+
if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1103
+
log.Println("unauthorized user")
1104
+
w.WriteHeader(http.StatusUnauthorized)
1105
+
return
1106
+
}
1107
+
1108
+
ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1109
+
if err != nil {
1110
+
log.Printf("failed to create client for %s: %s", f.Knot, err)
1111
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1112
+
return
1113
+
}
1114
+
1115
+
diffTreeResponse, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1116
+
if err != nil {
1117
+
log.Printf("compare request failed: %s", err)
1118
+
s.pages.Notice(w, "resubmit-error", err.Error())
1119
+
return
1120
+
}
1121
+
1122
+
sourceRev := diffTreeResponse.DiffTree.Rev2
1123
+
patch := diffTreeResponse.DiffTree.Patch
1124
+
1125
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1126
+
s.pages.Notice(w, "resubmit-error", err.Error())
1127
+
}
1128
+
1129
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1130
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1131
+
return
1132
+
}
1133
+
1134
+
tx, err := s.db.BeginTx(r.Context(), nil)
1135
+
if err != nil {
1136
+
log.Println("failed to start tx")
1137
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1138
+
return
1139
+
}
1140
+
defer tx.Rollback()
550
1141
551
-
err = db.ResubmitPull(tx, pull, patch)
552
-
if err != nil {
553
-
log.Println("failed to create pull request", err)
554
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
555
-
return
556
-
}
557
-
client, _ := s.auth.AuthorizedClient(r)
1142
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1143
+
if err != nil {
1144
+
log.Println("failed to create pull request", err)
1145
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1146
+
return
1147
+
}
1148
+
client, _ := s.auth.AuthorizedClient(r)
558
1149
559
-
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
560
-
if err != nil {
561
-
// failed to get record
562
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
563
-
return
564
-
}
1150
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1151
+
if err != nil {
1152
+
// failed to get record
1153
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1154
+
return
1155
+
}
565
1156
566
-
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
567
-
Collection: tangled.RepoPullNSID,
568
-
Repo: user.Did,
569
-
Rkey: pull.Rkey,
570
-
SwapRecord: ex.Cid,
571
-
Record: &lexutil.LexiconTypeDecoder{
572
-
Val: &tangled.RepoPull{
573
-
Title: pull.Title,
574
-
PullId: int64(pull.PullId),
575
-
TargetRepo: string(f.RepoAt),
576
-
TargetBranch: pull.TargetBranch,
577
-
Patch: patch, // new patch
578
-
},
1157
+
recordPullSource := &tangled.RepoPull_Source{
1158
+
Branch: pull.PullSource.Branch,
1159
+
}
1160
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1161
+
Collection: tangled.RepoPullNSID,
1162
+
Repo: user.Did,
1163
+
Rkey: pull.Rkey,
1164
+
SwapRecord: ex.Cid,
1165
+
Record: &lexutil.LexiconTypeDecoder{
1166
+
Val: &tangled.RepoPull{
1167
+
Title: pull.Title,
1168
+
PullId: int64(pull.PullId),
1169
+
TargetRepo: string(f.RepoAt),
1170
+
TargetBranch: pull.TargetBranch,
1171
+
Patch: patch, // new patch
1172
+
Source: recordPullSource,
579
1173
},
580
-
})
581
-
if err != nil {
582
-
log.Println("failed to update record", err)
583
-
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
584
-
return
585
-
}
1174
+
},
1175
+
})
1176
+
if err != nil {
1177
+
log.Println("failed to update record", err)
1178
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1179
+
return
1180
+
}
1181
+
1182
+
if err = tx.Commit(); err != nil {
1183
+
log.Println("failed to commit transaction", err)
1184
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1185
+
return
1186
+
}
1187
+
1188
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1189
+
return
1190
+
}
1191
+
1192
+
func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1193
+
user := s.auth.GetUser(r)
1194
+
1195
+
pull, ok := r.Context().Value("pull").(*db.Pull)
1196
+
if !ok {
1197
+
log.Println("failed to get pull")
1198
+
s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1199
+
return
1200
+
}
1201
+
1202
+
f, err := fullyResolvedRepo(r)
1203
+
if err != nil {
1204
+
log.Println("failed to get repo and knot", err)
1205
+
return
1206
+
}
1207
+
1208
+
if user.Did != pull.OwnerDid {
1209
+
log.Println("unauthorized user")
1210
+
w.WriteHeader(http.StatusUnauthorized)
1211
+
return
1212
+
}
1213
+
1214
+
forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1215
+
if err != nil {
1216
+
log.Println("failed to get source repo", err)
1217
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1218
+
return
1219
+
}
1220
+
1221
+
// extract patch by performing compare
1222
+
ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1223
+
if err != nil {
1224
+
log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1225
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1226
+
return
1227
+
}
1228
+
1229
+
secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1230
+
if err != nil {
1231
+
log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1232
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1233
+
return
1234
+
}
1235
+
1236
+
// update the hidden tracking branch to latest
1237
+
signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1238
+
if err != nil {
1239
+
log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1240
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1241
+
return
1242
+
}
1243
+
1244
+
resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1245
+
if err != nil || resp.StatusCode != http.StatusNoContent {
1246
+
log.Printf("failed to update tracking branch: %s", err)
1247
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1248
+
return
1249
+
}
1250
+
1251
+
hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1252
+
diffTreeResponse, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1253
+
if err != nil {
1254
+
log.Printf("failed to compare branches: %s", err)
1255
+
s.pages.Notice(w, "resubmit-error", err.Error())
1256
+
return
1257
+
}
1258
+
1259
+
sourceRev := diffTreeResponse.DiffTree.Rev2
1260
+
patch := diffTreeResponse.DiffTree.Patch
1261
+
1262
+
if err = validateResubmittedPatch(pull, patch); err != nil {
1263
+
s.pages.Notice(w, "resubmit-error", err.Error())
1264
+
}
1265
+
1266
+
if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1267
+
s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1268
+
return
1269
+
}
1270
+
1271
+
tx, err := s.db.BeginTx(r.Context(), nil)
1272
+
if err != nil {
1273
+
log.Println("failed to start tx")
1274
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1275
+
return
1276
+
}
1277
+
defer tx.Rollback()
1278
+
1279
+
err = db.ResubmitPull(tx, pull, patch, sourceRev)
1280
+
if err != nil {
1281
+
log.Println("failed to create pull request", err)
1282
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1283
+
return
1284
+
}
1285
+
client, _ := s.auth.AuthorizedClient(r)
1286
+
1287
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1288
+
if err != nil {
1289
+
// failed to get record
1290
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1291
+
return
1292
+
}
586
1293
587
-
if err = tx.Commit(); err != nil {
588
-
log.Println("failed to commit transaction", err)
589
-
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
590
-
return
591
-
}
1294
+
repoAt := pull.PullSource.RepoAt.String()
1295
+
recordPullSource := &tangled.RepoPull_Source{
1296
+
Branch: pull.PullSource.Branch,
1297
+
Repo: &repoAt,
1298
+
}
1299
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1300
+
Collection: tangled.RepoPullNSID,
1301
+
Repo: user.Did,
1302
+
Rkey: pull.Rkey,
1303
+
SwapRecord: ex.Cid,
1304
+
Record: &lexutil.LexiconTypeDecoder{
1305
+
Val: &tangled.RepoPull{
1306
+
Title: pull.Title,
1307
+
PullId: int64(pull.PullId),
1308
+
TargetRepo: string(f.RepoAt),
1309
+
TargetBranch: pull.TargetBranch,
1310
+
Patch: patch, // new patch
1311
+
Source: recordPullSource,
1312
+
},
1313
+
},
1314
+
})
1315
+
if err != nil {
1316
+
log.Println("failed to update record", err)
1317
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1318
+
return
1319
+
}
592
1320
593
-
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1321
+
if err = tx.Commit(); err != nil {
1322
+
log.Println("failed to commit transaction", err)
1323
+
s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
594
1324
return
595
1325
}
1326
+
1327
+
s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1328
+
return
1329
+
}
1330
+
1331
+
// validate a resubmission against a pull request
1332
+
func validateResubmittedPatch(pull *db.Pull, patch string) error {
1333
+
if patch == "" {
1334
+
return fmt.Errorf("Patch is empty.")
1335
+
}
1336
+
1337
+
if patch == pull.LatestPatch() {
1338
+
return fmt.Errorf("Patch is identical to previous submission.")
1339
+
}
1340
+
1341
+
if !isPatchValid(patch) {
1342
+
return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1343
+
}
1344
+
1345
+
return nil
596
1346
}
597
1347
598
1348
func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
···
617
1367
return
618
1368
}
619
1369
1370
+
ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1371
+
if err != nil {
1372
+
log.Printf("resolving identity: %s", err)
1373
+
w.WriteHeader(http.StatusNotFound)
1374
+
return
1375
+
}
1376
+
1377
+
email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1378
+
if err != nil {
1379
+
log.Printf("failed to get primary email: %s", err)
1380
+
}
1381
+
620
1382
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
621
1383
if err != nil {
622
1384
log.Printf("failed to create signed client for %s: %s", f.Knot, err)
···
625
1387
}
626
1388
627
1389
// Merge the pull request
628
-
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "")
1390
+
resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
629
1391
if err != nil {
630
1392
log.Printf("failed to merge pull request: %s", err)
631
1393
s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
+771
-6
appview/state/repo.go
+771
-6
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"
···
14
16
"strings"
15
17
"time"
16
18
19
+
"github.com/bluesky-social/indigo/atproto/data"
17
20
"github.com/bluesky-social/indigo/atproto/identity"
18
21
"github.com/bluesky-social/indigo/atproto/syntax"
19
22
securejoin "github.com/cyphar/filepath-securejoin"
20
23
"github.com/go-chi/chi/v5"
24
+
"github.com/go-git/go-git/v5/plumbing"
21
25
"tangled.sh/tangled.sh/core/api/tangled"
22
26
"tangled.sh/tangled.sh/core/appview/auth"
23
27
"tangled.sh/tangled.sh/core/appview/db"
24
28
"tangled.sh/tangled.sh/core/appview/pages"
29
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
25
30
"tangled.sh/tangled.sh/core/types"
26
31
27
32
comatproto "github.com/bluesky-social/indigo/api/atproto"
···
244
249
if !s.config.Dev {
245
250
protocol = "https"
246
251
}
252
+
253
+
if !plumbing.IsHash(ref) {
254
+
s.pages.Error404(w)
255
+
return
256
+
}
257
+
247
258
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
248
259
if err != nil {
249
260
log.Println("failed to reach knotserver", err)
···
450
461
}
451
462
}
452
463
464
+
showRendered := false
465
+
renderToggle := false
466
+
467
+
if markup.GetFormat(result.Path) == markup.FormatMarkdown {
468
+
renderToggle = true
469
+
showRendered = r.URL.Query().Get("code") != "true"
470
+
}
471
+
453
472
user := s.auth.GetUser(r)
454
473
s.pages.RepoBlob(w, pages.RepoBlobParams{
455
474
LoggedInUser: user,
456
475
RepoInfo: f.RepoInfo(s, user),
457
476
RepoBlobResponse: result,
458
477
BreadCrumbs: breadcrumbs,
478
+
ShowRendered: showRendered,
479
+
RenderToggle: renderToggle,
459
480
})
460
481
return
461
482
}
462
483
484
+
func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
485
+
f, err := fullyResolvedRepo(r)
486
+
if err != nil {
487
+
log.Println("failed to get repo and knot", err)
488
+
return
489
+
}
490
+
491
+
ref := chi.URLParam(r, "ref")
492
+
filePath := chi.URLParam(r, "*")
493
+
494
+
protocol := "http"
495
+
if !s.config.Dev {
496
+
protocol = "https"
497
+
}
498
+
resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
499
+
if err != nil {
500
+
log.Println("failed to reach knotserver", err)
501
+
return
502
+
}
503
+
504
+
body, err := io.ReadAll(resp.Body)
505
+
if err != nil {
506
+
log.Printf("Error reading response body: %v", err)
507
+
return
508
+
}
509
+
510
+
var result types.RepoBlobResponse
511
+
err = json.Unmarshal(body, &result)
512
+
if err != nil {
513
+
log.Println("failed to parse response:", err)
514
+
return
515
+
}
516
+
517
+
if result.IsBinary {
518
+
w.Header().Set("Content-Type", "application/octet-stream")
519
+
w.Write(body)
520
+
return
521
+
}
522
+
523
+
w.Header().Set("Content-Type", "text/plain")
524
+
w.Write([]byte(result.Contents))
525
+
return
526
+
}
527
+
463
528
func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
464
529
f, err := fullyResolvedRepo(r)
465
530
if err != nil {
···
549
614
550
615
}
551
616
617
+
func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
618
+
user := s.auth.GetUser(r)
619
+
620
+
f, err := fullyResolvedRepo(r)
621
+
if err != nil {
622
+
log.Println("failed to get repo and knot", err)
623
+
return
624
+
}
625
+
626
+
// remove record from pds
627
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
628
+
repoRkey := f.RepoAt.RecordKey().String()
629
+
_, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{
630
+
Collection: tangled.RepoNSID,
631
+
Repo: user.Did,
632
+
Rkey: repoRkey,
633
+
})
634
+
if err != nil {
635
+
log.Printf("failed to delete record: %s", err)
636
+
s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
637
+
return
638
+
}
639
+
log.Println("removed repo record ", f.RepoAt.String())
640
+
641
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
642
+
if err != nil {
643
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
644
+
return
645
+
}
646
+
647
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
648
+
if err != nil {
649
+
log.Println("failed to create client to ", f.Knot)
650
+
return
651
+
}
652
+
653
+
ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
654
+
if err != nil {
655
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
656
+
return
657
+
}
658
+
659
+
if ksResp.StatusCode != http.StatusNoContent {
660
+
log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
661
+
} else {
662
+
log.Println("removed repo from knot ", f.Knot)
663
+
}
664
+
665
+
tx, err := s.db.BeginTx(r.Context(), nil)
666
+
if err != nil {
667
+
log.Println("failed to start tx")
668
+
w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
669
+
return
670
+
}
671
+
defer func() {
672
+
tx.Rollback()
673
+
err = s.enforcer.E.LoadPolicy()
674
+
if err != nil {
675
+
log.Println("failed to rollback policies")
676
+
}
677
+
}()
678
+
679
+
// remove collaborator RBAC
680
+
repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
681
+
if err != nil {
682
+
s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
683
+
return
684
+
}
685
+
for _, c := range repoCollaborators {
686
+
did := c[0]
687
+
s.enforcer.RemoveCollaborator(did, f.Knot, f.OwnerSlashRepo())
688
+
}
689
+
log.Println("removed collaborators")
690
+
691
+
// remove repo RBAC
692
+
err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.OwnerSlashRepo())
693
+
if err != nil {
694
+
s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
695
+
return
696
+
}
697
+
698
+
// remove repo from db
699
+
err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
700
+
if err != nil {
701
+
s.pages.Notice(w, "settings-delete", "Failed to update appview")
702
+
return
703
+
}
704
+
log.Println("removed repo from db")
705
+
706
+
err = tx.Commit()
707
+
if err != nil {
708
+
log.Println("failed to commit changes", err)
709
+
http.Error(w, err.Error(), http.StatusInternalServerError)
710
+
return
711
+
}
712
+
713
+
err = s.enforcer.E.SavePolicy()
714
+
if err != nil {
715
+
log.Println("failed to update ACLs", err)
716
+
http.Error(w, err.Error(), http.StatusInternalServerError)
717
+
return
718
+
}
719
+
720
+
s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
721
+
}
722
+
723
+
func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
724
+
f, err := fullyResolvedRepo(r)
725
+
if err != nil {
726
+
log.Println("failed to get repo and knot", err)
727
+
return
728
+
}
729
+
730
+
branch := r.FormValue("branch")
731
+
if branch == "" {
732
+
http.Error(w, "malformed form", http.StatusBadRequest)
733
+
return
734
+
}
735
+
736
+
secret, err := db.GetRegistrationKey(s.db, f.Knot)
737
+
if err != nil {
738
+
log.Printf("no key found for domain %s: %s\n", f.Knot, err)
739
+
return
740
+
}
741
+
742
+
ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
743
+
if err != nil {
744
+
log.Println("failed to create client to ", f.Knot)
745
+
return
746
+
}
747
+
748
+
ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
749
+
if err != nil {
750
+
log.Printf("failed to make request to %s: %s", f.Knot, err)
751
+
return
752
+
}
753
+
754
+
if ksResp.StatusCode != http.StatusNoContent {
755
+
s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
756
+
return
757
+
}
758
+
759
+
w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
760
+
}
761
+
552
762
func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
553
763
f, err := fullyResolvedRepo(r)
554
764
if err != nil {
···
573
783
}
574
784
}
575
785
786
+
var branchNames []string
787
+
var defaultBranch string
788
+
us, err := NewUnsignedClient(f.Knot, s.config.Dev)
789
+
if err != nil {
790
+
log.Println("failed to create unsigned client", err)
791
+
} else {
792
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
793
+
if err != nil {
794
+
log.Println("failed to reach knotserver", err)
795
+
} else {
796
+
defer resp.Body.Close()
797
+
798
+
body, err := io.ReadAll(resp.Body)
799
+
if err != nil {
800
+
log.Printf("Error reading response body: %v", err)
801
+
} else {
802
+
var result types.RepoBranchesResponse
803
+
err = json.Unmarshal(body, &result)
804
+
if err != nil {
805
+
log.Println("failed to parse response:", err)
806
+
} else {
807
+
for _, branch := range result.Branches {
808
+
branchNames = append(branchNames, branch.Name)
809
+
}
810
+
}
811
+
}
812
+
}
813
+
814
+
resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName)
815
+
if err != nil {
816
+
log.Println("failed to reach knotserver", err)
817
+
} else {
818
+
defer resp.Body.Close()
819
+
820
+
body, err := io.ReadAll(resp.Body)
821
+
if err != nil {
822
+
log.Printf("Error reading response body: %v", err)
823
+
} else {
824
+
var result types.RepoDefaultBranchResponse
825
+
err = json.Unmarshal(body, &result)
826
+
if err != nil {
827
+
log.Println("failed to parse response:", err)
828
+
} else {
829
+
defaultBranch = result.Branch
830
+
}
831
+
}
832
+
}
833
+
}
834
+
576
835
s.pages.RepoSettings(w, pages.RepoSettingsParams{
577
836
LoggedInUser: user,
578
837
RepoInfo: f.RepoInfo(s, user),
579
838
Collaborators: repoCollaborators,
580
839
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
840
+
Branches: branchNames,
841
+
DefaultBranch: defaultBranch,
581
842
})
582
843
}
583
844
}
···
666
927
if err != nil {
667
928
log.Println("failed to get issue count for ", f.RepoAt)
668
929
}
930
+
source, err := db.GetRepoSource(s.db, f.RepoAt)
931
+
if errors.Is(err, sql.ErrNoRows) {
932
+
source = ""
933
+
} else if err != nil {
934
+
log.Println("failed to get repo source for ", f.RepoAt, err)
935
+
}
936
+
937
+
var sourceRepo *db.Repo
938
+
if source != "" {
939
+
sourceRepo, err = db.GetRepoByAtUri(s.db, source)
940
+
if err != nil {
941
+
log.Println("failed to get repo by at uri", err)
942
+
}
943
+
}
944
+
945
+
var sourceHandle *identity.Identity
946
+
if sourceRepo != nil {
947
+
sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
948
+
if err != nil {
949
+
log.Println("failed to resolve source repo", err)
950
+
}
951
+
}
669
952
670
953
knot := f.Knot
954
+
var disableFork bool
955
+
us, err := NewUnsignedClient(knot, s.config.Dev)
956
+
if err != nil {
957
+
log.Printf("failed to create unsigned client for %s: %v", knot, err)
958
+
} else {
959
+
resp, err := us.Branches(f.OwnerDid(), f.RepoName)
960
+
if err != nil {
961
+
log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
962
+
} else {
963
+
defer resp.Body.Close()
964
+
body, err := io.ReadAll(resp.Body)
965
+
if err != nil {
966
+
log.Printf("error reading branch response body: %v", err)
967
+
} else {
968
+
var branchesResp types.RepoBranchesResponse
969
+
if err := json.Unmarshal(body, &branchesResp); err != nil {
970
+
log.Printf("error parsing branch response: %v", err)
971
+
} else {
972
+
disableFork = false
973
+
}
974
+
975
+
if len(branchesResp.Branches) == 0 {
976
+
disableFork = true
977
+
}
978
+
}
979
+
}
980
+
}
981
+
671
982
if knot == "knot1.tangled.sh" {
672
983
knot = "tangled.sh"
673
984
}
674
985
675
-
return pages.RepoInfo{
986
+
repoInfo := pages.RepoInfo{
676
987
OwnerDid: f.OwnerDid(),
677
988
OwnerHandle: f.OwnerHandle(),
678
989
Name: f.RepoName,
···
686
997
IssueCount: issueCount,
687
998
PullCount: pullCount,
688
999
},
1000
+
DisableFork: disableFork,
689
1001
}
1002
+
1003
+
if sourceRepo != nil {
1004
+
repoInfo.Source = sourceRepo
1005
+
repoInfo.SourceHandle = sourceHandle.Handle.String()
1006
+
}
1007
+
1008
+
return repoInfo
690
1009
}
691
1010
692
1011
func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
···
863
1182
}
864
1183
}
865
1184
866
-
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1185
+
func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
867
1186
user := s.auth.GetUser(r)
868
1187
f, err := fullyResolvedRepo(r)
869
1188
if err != nil {
···
887
1206
return
888
1207
}
889
1208
890
-
commentId := rand.IntN(1000000)
1209
+
commentId := mathrand.IntN(1000000)
1210
+
rkey := s.TID()
891
1211
892
-
err := db.NewComment(s.db, &db.Comment{
1212
+
err := db.NewIssueComment(s.db, &db.Comment{
893
1213
OwnerDid: user.Did,
894
1214
RepoAt: f.RepoAt,
895
1215
Issue: issueIdInt,
896
1216
CommentId: commentId,
897
1217
Body: body,
1218
+
Rkey: rkey,
898
1219
})
899
1220
if err != nil {
900
1221
log.Println("failed to create comment", err)
···
917
1238
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
918
1239
Collection: tangled.RepoIssueCommentNSID,
919
1240
Repo: user.Did,
920
-
Rkey: s.TID(),
1241
+
Rkey: rkey,
921
1242
Record: &lexutil.LexiconTypeDecoder{
922
1243
Val: &tangled.RepoIssueComment{
923
1244
Repo: &atUri,
···
940
1261
}
941
1262
}
942
1263
1264
+
func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1265
+
user := s.auth.GetUser(r)
1266
+
f, err := fullyResolvedRepo(r)
1267
+
if err != nil {
1268
+
log.Println("failed to get repo and knot", err)
1269
+
return
1270
+
}
1271
+
1272
+
issueId := chi.URLParam(r, "issue")
1273
+
issueIdInt, err := strconv.Atoi(issueId)
1274
+
if err != nil {
1275
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1276
+
log.Println("failed to parse issue id", err)
1277
+
return
1278
+
}
1279
+
1280
+
commentId := chi.URLParam(r, "comment_id")
1281
+
commentIdInt, err := strconv.Atoi(commentId)
1282
+
if err != nil {
1283
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1284
+
log.Println("failed to parse issue id", err)
1285
+
return
1286
+
}
1287
+
1288
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1289
+
if err != nil {
1290
+
log.Println("failed to get issue", err)
1291
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1292
+
return
1293
+
}
1294
+
1295
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1296
+
if err != nil {
1297
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1298
+
return
1299
+
}
1300
+
1301
+
identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1302
+
if err != nil {
1303
+
log.Println("failed to resolve did")
1304
+
return
1305
+
}
1306
+
1307
+
didHandleMap := make(map[string]string)
1308
+
if !identity.Handle.IsInvalidHandle() {
1309
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1310
+
} else {
1311
+
didHandleMap[identity.DID.String()] = identity.DID.String()
1312
+
}
1313
+
1314
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1315
+
LoggedInUser: user,
1316
+
RepoInfo: f.RepoInfo(s, user),
1317
+
DidHandleMap: didHandleMap,
1318
+
Issue: issue,
1319
+
Comment: comment,
1320
+
})
1321
+
}
1322
+
1323
+
func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1324
+
user := s.auth.GetUser(r)
1325
+
f, err := fullyResolvedRepo(r)
1326
+
if err != nil {
1327
+
log.Println("failed to get repo and knot", err)
1328
+
return
1329
+
}
1330
+
1331
+
issueId := chi.URLParam(r, "issue")
1332
+
issueIdInt, err := strconv.Atoi(issueId)
1333
+
if err != nil {
1334
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1335
+
log.Println("failed to parse issue id", err)
1336
+
return
1337
+
}
1338
+
1339
+
commentId := chi.URLParam(r, "comment_id")
1340
+
commentIdInt, err := strconv.Atoi(commentId)
1341
+
if err != nil {
1342
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1343
+
log.Println("failed to parse issue id", err)
1344
+
return
1345
+
}
1346
+
1347
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1348
+
if err != nil {
1349
+
log.Println("failed to get issue", err)
1350
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1351
+
return
1352
+
}
1353
+
1354
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1355
+
if err != nil {
1356
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1357
+
return
1358
+
}
1359
+
1360
+
if comment.OwnerDid != user.Did {
1361
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1362
+
return
1363
+
}
1364
+
1365
+
switch r.Method {
1366
+
case http.MethodGet:
1367
+
s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1368
+
LoggedInUser: user,
1369
+
RepoInfo: f.RepoInfo(s, user),
1370
+
Issue: issue,
1371
+
Comment: comment,
1372
+
})
1373
+
case http.MethodPost:
1374
+
// extract form value
1375
+
newBody := r.FormValue("body")
1376
+
client, _ := s.auth.AuthorizedClient(r)
1377
+
rkey := comment.Rkey
1378
+
1379
+
// optimistic update
1380
+
edited := time.Now()
1381
+
err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1382
+
if err != nil {
1383
+
log.Println("failed to perferom update-description query", err)
1384
+
s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1385
+
return
1386
+
}
1387
+
1388
+
// rkey is optional, it was introduced later
1389
+
if comment.Rkey != "" {
1390
+
// update the record on pds
1391
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1392
+
if err != nil {
1393
+
// failed to get record
1394
+
log.Println(err, rkey)
1395
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1396
+
return
1397
+
}
1398
+
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1399
+
record, _ := data.UnmarshalJSON(value)
1400
+
1401
+
repoAt := record["repo"].(string)
1402
+
issueAt := record["issue"].(string)
1403
+
createdAt := record["createdAt"].(string)
1404
+
commentIdInt64 := int64(commentIdInt)
1405
+
1406
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1407
+
Collection: tangled.RepoIssueCommentNSID,
1408
+
Repo: user.Did,
1409
+
Rkey: rkey,
1410
+
SwapRecord: ex.Cid,
1411
+
Record: &lexutil.LexiconTypeDecoder{
1412
+
Val: &tangled.RepoIssueComment{
1413
+
Repo: &repoAt,
1414
+
Issue: issueAt,
1415
+
CommentId: &commentIdInt64,
1416
+
Owner: &comment.OwnerDid,
1417
+
Body: &newBody,
1418
+
CreatedAt: &createdAt,
1419
+
},
1420
+
},
1421
+
})
1422
+
if err != nil {
1423
+
log.Println(err)
1424
+
}
1425
+
}
1426
+
1427
+
// optimistic update for htmx
1428
+
didHandleMap := map[string]string{
1429
+
user.Did: user.Handle,
1430
+
}
1431
+
comment.Body = newBody
1432
+
comment.Edited = &edited
1433
+
1434
+
// return new comment body with htmx
1435
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1436
+
LoggedInUser: user,
1437
+
RepoInfo: f.RepoInfo(s, user),
1438
+
DidHandleMap: didHandleMap,
1439
+
Issue: issue,
1440
+
Comment: comment,
1441
+
})
1442
+
return
1443
+
1444
+
}
1445
+
1446
+
}
1447
+
1448
+
func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1449
+
user := s.auth.GetUser(r)
1450
+
f, err := fullyResolvedRepo(r)
1451
+
if err != nil {
1452
+
log.Println("failed to get repo and knot", err)
1453
+
return
1454
+
}
1455
+
1456
+
issueId := chi.URLParam(r, "issue")
1457
+
issueIdInt, err := strconv.Atoi(issueId)
1458
+
if err != nil {
1459
+
http.Error(w, "bad issue id", http.StatusBadRequest)
1460
+
log.Println("failed to parse issue id", err)
1461
+
return
1462
+
}
1463
+
1464
+
issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1465
+
if err != nil {
1466
+
log.Println("failed to get issue", err)
1467
+
s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1468
+
return
1469
+
}
1470
+
1471
+
commentId := chi.URLParam(r, "comment_id")
1472
+
commentIdInt, err := strconv.Atoi(commentId)
1473
+
if err != nil {
1474
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1475
+
log.Println("failed to parse issue id", err)
1476
+
return
1477
+
}
1478
+
1479
+
comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1480
+
if err != nil {
1481
+
http.Error(w, "bad comment id", http.StatusBadRequest)
1482
+
return
1483
+
}
1484
+
1485
+
if comment.OwnerDid != user.Did {
1486
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1487
+
return
1488
+
}
1489
+
1490
+
if comment.Deleted != nil {
1491
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
1492
+
return
1493
+
}
1494
+
1495
+
// optimistic deletion
1496
+
deleted := time.Now()
1497
+
err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1498
+
if err != nil {
1499
+
log.Println("failed to delete comment")
1500
+
s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1501
+
return
1502
+
}
1503
+
1504
+
// delete from pds
1505
+
if comment.Rkey != "" {
1506
+
client, _ := s.auth.AuthorizedClient(r)
1507
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1508
+
Collection: tangled.GraphFollowNSID,
1509
+
Repo: user.Did,
1510
+
Rkey: comment.Rkey,
1511
+
})
1512
+
if err != nil {
1513
+
log.Println(err)
1514
+
}
1515
+
}
1516
+
1517
+
// optimistic update for htmx
1518
+
didHandleMap := map[string]string{
1519
+
user.Did: user.Handle,
1520
+
}
1521
+
comment.Body = ""
1522
+
comment.Deleted = &deleted
1523
+
1524
+
// htmx fragment of comment after deletion
1525
+
s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1526
+
LoggedInUser: user,
1527
+
RepoInfo: f.RepoInfo(s, user),
1528
+
DidHandleMap: didHandleMap,
1529
+
Issue: issue,
1530
+
Comment: comment,
1531
+
})
1532
+
return
1533
+
}
1534
+
943
1535
func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
944
1536
params := r.URL.Query()
945
1537
state := params.Get("state")
···
1073
1665
return
1074
1666
}
1075
1667
}
1668
+
1669
+
func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1670
+
user := s.auth.GetUser(r)
1671
+
f, err := fullyResolvedRepo(r)
1672
+
if err != nil {
1673
+
log.Printf("failed to resolve source repo: %v", err)
1674
+
return
1675
+
}
1676
+
1677
+
switch r.Method {
1678
+
case http.MethodGet:
1679
+
user := s.auth.GetUser(r)
1680
+
knots, err := s.enforcer.GetDomainsForUser(user.Did)
1681
+
if err != nil {
1682
+
s.pages.Notice(w, "repo", "Invalid user account.")
1683
+
return
1684
+
}
1685
+
1686
+
s.pages.ForkRepo(w, pages.ForkRepoParams{
1687
+
LoggedInUser: user,
1688
+
Knots: knots,
1689
+
RepoInfo: f.RepoInfo(s, user),
1690
+
})
1691
+
1692
+
case http.MethodPost:
1693
+
1694
+
knot := r.FormValue("knot")
1695
+
if knot == "" {
1696
+
s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1697
+
return
1698
+
}
1699
+
1700
+
ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1701
+
if err != nil || !ok {
1702
+
s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1703
+
return
1704
+
}
1705
+
1706
+
forkName := fmt.Sprintf("%s", f.RepoName)
1707
+
1708
+
// this check is *only* to see if the forked repo name already exists
1709
+
// in the user's account.
1710
+
existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1711
+
if err != nil {
1712
+
if errors.Is(err, sql.ErrNoRows) {
1713
+
// no existing repo with this name found, we can use the name as is
1714
+
} else {
1715
+
log.Println("error fetching existing repo from db", err)
1716
+
s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1717
+
return
1718
+
}
1719
+
} else if existingRepo != nil {
1720
+
// repo with this name already exists, append random string
1721
+
forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1722
+
}
1723
+
secret, err := db.GetRegistrationKey(s.db, knot)
1724
+
if err != nil {
1725
+
s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1726
+
return
1727
+
}
1728
+
1729
+
client, err := NewSignedClient(knot, secret, s.config.Dev)
1730
+
if err != nil {
1731
+
s.pages.Notice(w, "repo", "Failed to reach knot server.")
1732
+
return
1733
+
}
1734
+
1735
+
var uri string
1736
+
if s.config.Dev {
1737
+
uri = "http"
1738
+
} else {
1739
+
uri = "https"
1740
+
}
1741
+
sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1742
+
sourceAt := f.RepoAt.String()
1743
+
1744
+
rkey := s.TID()
1745
+
repo := &db.Repo{
1746
+
Did: user.Did,
1747
+
Name: forkName,
1748
+
Knot: knot,
1749
+
Rkey: rkey,
1750
+
Source: sourceAt,
1751
+
}
1752
+
1753
+
tx, err := s.db.BeginTx(r.Context(), nil)
1754
+
if err != nil {
1755
+
log.Println(err)
1756
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1757
+
return
1758
+
}
1759
+
defer func() {
1760
+
tx.Rollback()
1761
+
err = s.enforcer.E.LoadPolicy()
1762
+
if err != nil {
1763
+
log.Println("failed to rollback policies")
1764
+
}
1765
+
}()
1766
+
1767
+
resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
1768
+
if err != nil {
1769
+
s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1770
+
return
1771
+
}
1772
+
1773
+
switch resp.StatusCode {
1774
+
case http.StatusConflict:
1775
+
s.pages.Notice(w, "repo", "A repository with that name already exists.")
1776
+
return
1777
+
case http.StatusInternalServerError:
1778
+
s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1779
+
case http.StatusNoContent:
1780
+
// continue
1781
+
}
1782
+
1783
+
xrpcClient, _ := s.auth.AuthorizedClient(r)
1784
+
1785
+
addedAt := time.Now().Format(time.RFC3339)
1786
+
atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
1787
+
Collection: tangled.RepoNSID,
1788
+
Repo: user.Did,
1789
+
Rkey: rkey,
1790
+
Record: &lexutil.LexiconTypeDecoder{
1791
+
Val: &tangled.Repo{
1792
+
Knot: repo.Knot,
1793
+
Name: repo.Name,
1794
+
AddedAt: &addedAt,
1795
+
Owner: user.Did,
1796
+
Source: &sourceAt,
1797
+
}},
1798
+
})
1799
+
if err != nil {
1800
+
log.Printf("failed to create record: %s", err)
1801
+
s.pages.Notice(w, "repo", "Failed to announce repository creation.")
1802
+
return
1803
+
}
1804
+
log.Println("created repo record: ", atresp.Uri)
1805
+
1806
+
repo.AtUri = atresp.Uri
1807
+
err = db.AddRepo(tx, repo)
1808
+
if err != nil {
1809
+
log.Println(err)
1810
+
s.pages.Notice(w, "repo", "Failed to save repository information.")
1811
+
return
1812
+
}
1813
+
1814
+
// acls
1815
+
p, _ := securejoin.SecureJoin(user.Did, forkName)
1816
+
err = s.enforcer.AddRepo(user.Did, knot, p)
1817
+
if err != nil {
1818
+
log.Println(err)
1819
+
s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1820
+
return
1821
+
}
1822
+
1823
+
err = tx.Commit()
1824
+
if err != nil {
1825
+
log.Println("failed to commit changes", err)
1826
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1827
+
return
1828
+
}
1829
+
1830
+
err = s.enforcer.E.SavePolicy()
1831
+
if err != nil {
1832
+
log.Println("failed to update ACLs", err)
1833
+
http.Error(w, err.Error(), http.StatusInternalServerError)
1834
+
return
1835
+
}
1836
+
1837
+
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1838
+
return
1839
+
}
1840
+
}
+14
appview/state/repo_util.go
+14
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"
···
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
+
}
+26
-8
appview/state/router.go
+26
-8
appview/state/router.go
···
63
63
r.Get("/branches", s.RepoBranches)
64
64
r.Get("/tags", s.RepoTags)
65
65
r.Get("/blob/{ref}/*", s.RepoBlob)
66
+
r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw)
66
67
67
68
r.Route("/issues", func(r chi.Router) {
68
69
r.Get("/", s.RepoIssues)
···
72
73
r.Use(AuthMiddleware(s))
73
74
r.Get("/new", s.NewIssue)
74
75
r.Post("/new", s.NewIssue)
75
-
r.Post("/{issue}/comment", s.IssueComment)
76
+
r.Post("/{issue}/comment", s.NewIssueComment)
77
+
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
78
+
r.Get("/", s.IssueComment)
79
+
r.Delete("/", s.DeleteIssueComment)
80
+
r.Get("/edit", s.EditIssueComment)
81
+
r.Post("/edit", s.EditIssueComment)
82
+
})
76
83
r.Post("/{issue}/close", s.CloseIssue)
77
84
r.Post("/{issue}/reopen", s.ReopenIssue)
78
85
})
79
86
})
80
87
88
+
r.Route("/fork", func(r chi.Router) {
89
+
r.Use(AuthMiddleware(s))
90
+
r.Get("/", s.ForkRepo)
91
+
r.Post("/", s.ForkRepo)
92
+
})
93
+
81
94
r.Route("/pulls", func(r chi.Router) {
82
95
r.Get("/", s.RepoPulls)
83
96
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
84
97
r.Get("/", s.NewPull)
98
+
r.Get("/patch-upload", s.PatchUploadFragment)
99
+
r.Get("/compare-branches", s.CompareBranchesFragment)
100
+
r.Get("/compare-forks", s.CompareForksFragment)
101
+
r.Get("/fork-branches", s.CompareForksBranchesFragment)
85
102
r.Post("/", s.NewPull)
86
103
})
87
104
···
92
109
r.Route("/round/{round}", func(r chi.Router) {
93
110
r.Get("/", s.RepoPullPatch)
94
111
r.Get("/actions", s.PullActions)
95
-
r.Route("/comment", func(r chi.Router) {
112
+
r.With(AuthMiddleware(s)).Route("/comment", func(r chi.Router) {
96
113
r.Get("/", s.PullComment)
97
114
r.Post("/", s.PullComment)
98
115
})
99
116
})
100
117
101
-
// authorized requests below this point
118
+
r.Route("/round/{round}.patch", func(r chi.Router) {
119
+
r.Get("/", s.RepoPullPatchRaw)
120
+
})
121
+
102
122
r.Group(func(r chi.Router) {
103
123
r.Use(AuthMiddleware(s))
104
124
r.Route("/resubmit", func(r chi.Router) {
105
125
r.Get("/", s.ResubmitPull)
106
126
r.Post("/", s.ResubmitPull)
107
127
})
108
-
r.Route("/comment", func(r chi.Router) {
109
-
r.Get("/", s.PullComment)
110
-
r.Post("/", s.PullComment)
111
-
})
112
128
r.Post("/close", s.ClosePull)
113
129
r.Post("/reopen", s.ReopenPull)
114
130
// collaborators only
···
137
153
r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) {
138
154
r.Get("/", s.RepoSettings)
139
155
r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator)
156
+
r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo)
157
+
r.Put("/branches/default", s.SetDefaultBranch)
140
158
})
141
159
})
142
160
})
···
156
174
157
175
r.Get("/", s.Timeline)
158
176
159
-
r.With(AuthMiddleware(s)).Get("/logout", s.Logout)
177
+
r.With(AuthMiddleware(s)).Post("/logout", s.Logout)
160
178
161
179
r.Route("/login", func(r chi.Router) {
162
180
r.Get("/", s.Login)
+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.RepoDiffTreeResponse, 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 diffTreeResponse types.RepoDiffTreeResponse
413
+
err = json.Unmarshal(respBody, &diffTreeResponse)
414
+
if err != nil {
415
+
log.Println("failed to unmarshal diff tree response", err)
416
+
return nil, fmt.Errorf("Failed to compare branches.")
417
+
}
418
+
419
+
return &diffTreeResponse, nil
420
+
}
+2
-2
appview/state/star.go
+2
-2
appview/state/star.go
···
62
62
63
63
log.Println("created atproto record: ", resp.Uri)
64
64
65
-
s.pages.StarFragment(w, pages.StarFragmentParams{
65
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
66
66
IsStarred: true,
67
67
RepoAt: subjectUri,
68
68
Stats: db.RepoStats{
···
101
101
log.Println("failed to get star count for ", subjectUri)
102
102
}
103
103
104
-
s.pages.StarFragment(w, pages.StarFragmentParams{
104
+
s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{
105
105
IsStarred: false,
106
106
RepoAt: subjectUri,
107
107
Stats: db.RepoStats{
+30
-71
appview/state/state.go
+30
-71
appview/state/state.go
···
60
60
resolver := appview.NewResolver()
61
61
62
62
wrapper := db.DbWrapper{d}
63
-
jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false)
63
+
jc, err := jetstream.NewJetstreamClient(
64
+
config.JetstreamEndpoint,
65
+
"appview",
66
+
[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID},
67
+
nil,
68
+
slog.Default(),
69
+
wrapper,
70
+
false,
71
+
)
64
72
if err != nil {
65
73
return nil, fmt.Errorf("failed to create jetstream client: %w", err)
66
74
}
···
165
173
166
174
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
167
175
s.auth.ClearSession(r, w)
168
-
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
176
+
w.Header().Set("HX-Redirect", "/login")
177
+
w.WriteHeader(http.StatusSeeOther)
169
178
}
170
179
171
180
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
···
181
190
for _, ev := range timeline {
182
191
if ev.Repo != nil {
183
192
didsToResolve = append(didsToResolve, ev.Repo.Did)
193
+
if ev.Source != nil {
194
+
didsToResolve = append(didsToResolve, ev.Source.Did)
195
+
}
184
196
}
185
197
if ev.Follow != nil {
186
198
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
···
419
431
}
420
432
}
421
433
434
+
var didsToResolve []string
435
+
for _, m := range members {
436
+
didsToResolve = append(didsToResolve, m)
437
+
}
438
+
didsToResolve = append(didsToResolve, reg.ByDid)
439
+
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
440
+
didHandleMap := make(map[string]string)
441
+
for _, identity := range resolvedIds {
442
+
if !identity.Handle.IsInvalidHandle() {
443
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
444
+
} else {
445
+
didHandleMap[identity.DID.String()] = identity.DID.String()
446
+
}
447
+
}
448
+
422
449
ok, err := s.enforcer.IsServerOwner(user.Did, domain)
423
450
isOwner := err == nil && ok
424
451
425
452
p := pages.KnotParams{
426
453
LoggedInUser: user,
454
+
DidHandleMap: didHandleMap,
427
455
Registration: reg,
428
456
Members: members,
429
457
IsOwner: isOwner,
···
713
741
s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))
714
742
return
715
743
}
716
-
}
717
-
718
-
func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) {
719
-
didOrHandle := chi.URLParam(r, "user")
720
-
if didOrHandle == "" {
721
-
http.Error(w, "Bad request", http.StatusBadRequest)
722
-
return
723
-
}
724
-
725
-
ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle)
726
-
if err != nil {
727
-
log.Printf("resolving identity: %s", err)
728
-
w.WriteHeader(http.StatusNotFound)
729
-
return
730
-
}
731
-
732
-
repos, err := db.GetAllReposByDid(s.db, ident.DID.String())
733
-
if err != nil {
734
-
log.Printf("getting repos for %s: %s", ident.DID.String(), err)
735
-
}
736
-
737
-
collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String())
738
-
if err != nil {
739
-
log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err)
740
-
}
741
-
var didsToResolve []string
742
-
for _, r := range collaboratingRepos {
743
-
didsToResolve = append(didsToResolve, r.Did)
744
-
}
745
-
resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve)
746
-
didHandleMap := make(map[string]string)
747
-
for _, identity := range resolvedIds {
748
-
if !identity.Handle.IsInvalidHandle() {
749
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
750
-
} else {
751
-
didHandleMap[identity.DID.String()] = identity.DID.String()
752
-
}
753
-
}
754
-
755
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
756
-
if err != nil {
757
-
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
758
-
}
759
-
760
-
loggedInUser := s.auth.GetUser(r)
761
-
followStatus := db.IsNotFollowing
762
-
if loggedInUser != nil {
763
-
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
764
-
}
765
-
766
-
profileAvatarUri, err := GetAvatarUri(ident.Handle.String())
767
-
if err != nil {
768
-
log.Println("failed to fetch bsky avatar", err)
769
-
}
770
-
771
-
s.pages.ProfilePage(w, pages.ProfilePageParams{
772
-
LoggedInUser: loggedInUser,
773
-
UserDid: ident.DID.String(),
774
-
UserHandle: ident.Handle.String(),
775
-
Repos: repos,
776
-
CollaboratingRepos: collaboratingRepos,
777
-
ProfileStats: pages.ProfileStats{
778
-
Followers: followers,
779
-
Following: following,
780
-
},
781
-
FollowStatus: db.FollowStatus(followStatus),
782
-
DidHandleMap: didHandleMap,
783
-
AvatarUri: profileAvatarUri,
784
-
})
785
744
}
786
745
787
746
func GetAvatarUri(handle string) (string, error) {
+1
cmd/gen.go
+1
cmd/gen.go
-150
cmd/jstest/main.go
-150
cmd/jstest/main.go
···
1
-
package main
2
-
3
-
import (
4
-
"context"
5
-
"flag"
6
-
"log/slog"
7
-
"os"
8
-
"os/signal"
9
-
"strings"
10
-
"syscall"
11
-
"time"
12
-
13
-
"github.com/bluesky-social/jetstream/pkg/client"
14
-
"github.com/bluesky-social/jetstream/pkg/models"
15
-
"tangled.sh/tangled.sh/core/jetstream"
16
-
)
17
-
18
-
// Simple in-memory implementation of DB interface
19
-
type MemoryDB struct {
20
-
lastTimeUs int64
21
-
}
22
-
23
-
func (m *MemoryDB) GetLastTimeUs() (int64, error) {
24
-
if m.lastTimeUs == 0 {
25
-
return time.Now().UnixMicro(), nil
26
-
}
27
-
return m.lastTimeUs, nil
28
-
}
29
-
30
-
func (m *MemoryDB) SaveLastTimeUs(ts int64) error {
31
-
m.lastTimeUs = ts
32
-
return nil
33
-
}
34
-
35
-
func (m *MemoryDB) UpdateLastTimeUs(ts int64) error {
36
-
m.lastTimeUs = ts
37
-
return nil
38
-
}
39
-
40
-
func main() {
41
-
// Setup logger
42
-
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
43
-
Level: slog.LevelInfo,
44
-
}))
45
-
46
-
// Create in-memory DB
47
-
db := &MemoryDB{}
48
-
49
-
// Get query URL from flag
50
-
var queryURL string
51
-
flag.StringVar(&queryURL, "query-url", "", "Jetstream query URL containing DIDs")
52
-
flag.Parse()
53
-
54
-
if queryURL == "" {
55
-
logger.Error("No query URL provided, use --query-url flag")
56
-
os.Exit(1)
57
-
}
58
-
59
-
// Extract wantedDids parameters
60
-
didParams := strings.Split(queryURL, "&wantedDids=")
61
-
dids := make([]string, 0, len(didParams)-1)
62
-
for i, param := range didParams {
63
-
if i == 0 {
64
-
// Skip the first part (the base URL with cursor)
65
-
continue
66
-
}
67
-
dids = append(dids, param)
68
-
}
69
-
70
-
// Extract collections
71
-
collections := []string{"sh.tangled.publicKey", "sh.tangled.knot.member"}
72
-
73
-
// Create client configuration
74
-
cfg := client.DefaultClientConfig()
75
-
cfg.WebsocketURL = "wss://jetstream2.us-west.bsky.network/subscribe"
76
-
cfg.WantedCollections = collections
77
-
78
-
// Create jetstream client
79
-
jsClient, err := jetstream.NewJetstreamClient(
80
-
cfg.WebsocketURL,
81
-
"tangled-jetstream",
82
-
collections,
83
-
cfg,
84
-
logger,
85
-
db,
86
-
false,
87
-
)
88
-
if err != nil {
89
-
logger.Error("Failed to create jetstream client", "error", err)
90
-
os.Exit(1)
91
-
}
92
-
93
-
// Update DIDs
94
-
jsClient.UpdateDids(dids)
95
-
96
-
// Create a context that will be canceled on SIGINT or SIGTERM
97
-
ctx, cancel := context.WithCancel(context.Background())
98
-
defer cancel()
99
-
100
-
// Setup signal handling with a buffered channel
101
-
sigCh := make(chan os.Signal, 1)
102
-
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
103
-
104
-
// Process function for events
105
-
processFunc := func(ctx context.Context, event *models.Event) error {
106
-
// Log the event details
107
-
logger.Info("Received event",
108
-
"collection", event.Commit.Collection,
109
-
"did", event.Did,
110
-
"rkey", event.Commit.RKey,
111
-
"action", event.Kind,
112
-
"time_us", event.TimeUS,
113
-
)
114
-
115
-
// Save the last time_us
116
-
if err := db.UpdateLastTimeUs(event.TimeUS); err != nil {
117
-
logger.Error("Failed to update last time_us", "error", err)
118
-
}
119
-
120
-
return nil
121
-
}
122
-
123
-
// Start jetstream
124
-
if err := jsClient.StartJetstream(ctx, processFunc); err != nil {
125
-
logger.Error("Failed to start jetstream", "error", err)
126
-
os.Exit(1)
127
-
}
128
-
129
-
// Wait for signal instead of context.Done()
130
-
sig := <-sigCh
131
-
logger.Info("Received signal, shutting down", "signal", sig)
132
-
cancel() // Cancel context after receiving signal
133
-
134
-
// Shutdown gracefully with a timeout
135
-
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
136
-
defer shutdownCancel()
137
-
138
-
done := make(chan struct{})
139
-
go func() {
140
-
jsClient.Shutdown()
141
-
close(done)
142
-
}()
143
-
144
-
select {
145
-
case <-done:
146
-
logger.Info("Jetstream client shut down gracefully")
147
-
case <-shutdownCtx.Done():
148
-
logger.Warn("Shutdown timed out, forcing exit")
149
-
}
150
-
}
+1
-1
cmd/knotserver/main.go
+1
-1
cmd/knotserver/main.go
···
49
49
jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{
50
50
tangled.PublicKeyNSID,
51
51
tangled.KnotMemberNSID,
52
-
}, nil, l, db, false)
52
+
}, nil, l, db, true)
53
53
if err != nil {
54
54
l.Error("failed to setup jetstream", "error", err)
55
55
}
+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.example.org"
8
+
KNOT_SERVER_SECRET: "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
+74
docs/contributing.md
+74
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>: <package/path>: <short summary of change>
15
+
16
+
17
+
Optional longer description, if needed. Explain what the change does and
18
+
why, especially if not obvious. Reference relevant issues or PRs when
19
+
applicable. These can be links for now since we don't auto-link
20
+
issues/PRs yet.
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 a single commit, so keep PRs small and focused. Use
39
+
the above guidelines for the PR title and description.
40
+
- Use the imperative mood in the summary line (e.g., "fix bug" not
41
+
"fixed bug" or "fixes bug").
42
+
- Try to keep the summary line under 72 characters, but we aren't too
43
+
fussed about this.
44
+
- Don't include unrelated changes in the same commit.
45
+
- Avoid noisy commit messages like "wip" or "final fix"โrewrite history
46
+
before submitting if necessary.
47
+
48
+
## proposals for bigger changes
49
+
50
+
Small fixes like typos, minor bugs, or trivial refactors can be
51
+
submitted directly as PRs.
52
+
53
+
For larger changesโespecially those introducing new features,
54
+
significant refactoring, or altering system behaviorโplease open a
55
+
proposal first. This helps us evaluate the scope, design, and potential
56
+
impact before implementation.
57
+
58
+
### proposal format
59
+
60
+
Create a new issue titled:
61
+
62
+
```
63
+
proposal: <affected scope>: <summary of change>
64
+
```
65
+
66
+
In the description, explain:
67
+
68
+
- What the change is
69
+
- Why it's needed
70
+
- How you plan to implement it (roughly)
71
+
- Any open questions or tradeoffs
72
+
73
+
We'll use the issue thread to discuss and refine the idea before moving
74
+
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.
+26
-14
flake.lock
+26
-14
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": {
···
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": 1740938536,
83
-
"narHash": "sha256-m6Lz7cRoZ8GS7tziYrNWv0WXTYtKx3oOC9Bwa6a13EA=",
92
+
"lastModified": 1743813633,
93
+
"narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=",
84
94
"owner": "nixos",
85
95
"repo": "nixpkgs",
86
-
"rev": "2ffed2bc3d27861b821f9bec127cf51a4dbfabb4",
96
+
"rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6",
87
97
"type": "github"
88
98
},
89
99
"original": {
90
100
"owner": "nixos",
101
+
"ref": "nixos-24.11",
91
102
"repo": "nixpkgs",
92
103
"type": "github"
93
104
}
···
96
107
"inputs": {
97
108
"gitignore": "gitignore",
98
109
"htmx-src": "htmx-src",
99
-
"ia-fonts-src": "ia-fonts-src",
110
+
"ibm-plex-mono-src": "ibm-plex-mono-src",
100
111
"indigo": "indigo",
112
+
"inter-fonts-src": "inter-fonts-src",
101
113
"lucide-src": "lucide-src",
102
114
"nixpkgs": "nixpkgs"
103
115
}
+72
-37
flake.nix
+72
-37
flake.nix
···
2
2
description = "atproto github";
3
3
4
4
inputs = {
5
-
nixpkgs.url = "github:nixos/nixpkgs";
5
+
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
6
6
indigo = {
7
7
url = "github:oppiliappan/indigo";
8
8
flake = false;
···
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-3gmXhututsJTFVPQi2uekTBP/qSJGgsDsVr7YU+z7d0=";
52
+
goModHash = "sha256-2vljseczrvsl2T0P9k69ro72yU59l5fp9r/sszmXYY4=";
48
53
buildCmdPackage = name:
49
54
final.buildGoModule {
50
55
pname = name;
···
52
57
src = gitignoreSource ./.;
53
58
subPackages = ["cmd/${name}"];
54
59
vendorHash = goModHash;
55
-
env.CGO_ENABLED = 0;
60
+
CGO_ENABLED = 0;
56
61
};
57
62
in {
58
63
indigo-lexgen = final.buildGoModule {
···
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
'';
82
88
doCheck = false;
83
89
subPackages = ["cmd/appview"];
84
90
vendorHash = goModHash;
85
-
env.CGO_ENABLED = 1;
91
+
CGO_ENABLED = 1;
86
92
stdenv = pkgsStatic.stdenv;
87
93
};
88
94
···
105
111
106
112
runHook postInstall
107
113
'';
108
-
env.CGO_ENABLED = 1;
114
+
CGO_ENABLED = 1;
109
115
};
110
116
knotserver-unwrapped = final.pkgsStatic.buildGoModule {
111
117
pname = "knotserver";
···
113
119
src = gitignoreSource ./.;
114
120
subPackages = ["cmd/knotserver"];
115
121
vendorHash = goModHash;
116
-
env.CGO_ENABLED = 1;
122
+
CGO_ENABLED = 1;
117
123
};
118
124
repoguard = buildCmdPackage "repoguard";
119
125
keyfetch = buildCmdPackage "keyfetch";
···
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
});
···
230
237
pkgs,
231
238
lib,
232
239
...
233
-
}:
240
+
}: let
241
+
cfg = config.services.tangled-knotserver;
242
+
in
234
243
with lib; {
235
244
options = {
236
245
services.tangled-knotserver = {
···
252
261
description = "User that hosts git repos and performs git operations";
253
262
};
254
263
264
+
openFirewall = mkOption {
265
+
type = types.bool;
266
+
default = true;
267
+
description = "Open port 22 in the firewall for ssh";
268
+
};
269
+
270
+
stateDir = mkOption {
271
+
type = types.path;
272
+
default = "/home/${cfg.gitUser}";
273
+
description = "Tangled knot data directory";
274
+
};
275
+
255
276
repo = {
256
277
scanPath = mkOption {
257
278
type = types.path;
258
-
default = "/home/git";
279
+
default = cfg.stateDir;
259
280
description = "Path where repositories are scanned from";
260
281
};
261
282
···
287
308
288
309
dbPath = mkOption {
289
310
type = types.path;
290
-
default = "knotserver.db";
311
+
default = "${cfg.stateDir}/knotserver.db";
291
312
description = "Path to the database file";
292
313
};
293
314
···
306
327
};
307
328
};
308
329
309
-
config = mkIf config.services.tangled-knotserver.enable {
330
+
config = mkIf cfg.enable {
310
331
environment.systemPackages = with pkgs; [git];
311
332
312
333
system.activationScripts.gitConfig = ''
313
-
mkdir -p /home/git/.config/git
314
-
cat > /home/git/.config/git/config << EOF
334
+
mkdir -p "${cfg.repo.scanPath}"
335
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
336
+
"${cfg.repo.scanPath}"
337
+
338
+
mkdir -p "${cfg.stateDir}/.config/git"
339
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
315
340
[user]
316
341
name = Git User
317
342
email = git@example.com
318
343
EOF
319
-
chown -R git:git /home/git/.config
344
+
chown -R ${cfg.gitUser}:${cfg.gitUser} \
345
+
"${cfg.stateDir}"
320
346
'';
321
347
322
-
users.users.git = {
323
-
isNormalUser = true;
324
-
home = "/home/git";
348
+
users.users.${cfg.gitUser} = {
349
+
isSystemUser = true;
350
+
useDefaultShell = true;
351
+
home = cfg.stateDir;
325
352
createHome = true;
326
-
group = "git";
353
+
group = cfg.gitUser;
327
354
};
328
355
329
-
users.groups.git = {};
356
+
users.groups.${cfg.gitUser} = {};
330
357
331
358
services.openssh = {
332
359
enable = true;
333
360
extraConfig = ''
334
-
Match User git
361
+
Match User ${cfg.gitUser}
335
362
AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper
336
363
AuthorizedKeysCommandUser nobody
337
364
'';
···
343
370
#!${pkgs.stdenv.shell}
344
371
${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \
345
372
-repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \
373
+
-internal-api "http://${cfg.server.internalListenAddr}" \
374
+
-git-dir "${cfg.repo.scanPath}" \
346
375
-log-path /tmp/repoguard.log
347
376
'';
348
377
};
···
352
381
after = ["network.target" "sshd.service"];
353
382
wantedBy = ["multi-user.target"];
354
383
serviceConfig = {
355
-
User = "git";
356
-
WorkingDirectory = "/home/git";
384
+
User = cfg.gitUser;
385
+
WorkingDirectory = cfg.stateDir;
357
386
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}"
387
+
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
388
+
"KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}"
389
+
"APPVIEW_ENDPOINT=${cfg.appviewEndpoint}"
390
+
"KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}"
391
+
"KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}"
392
+
"KNOT_SERVER_DB_PATH=${cfg.server.dbPath}"
393
+
"KNOT_SERVER_HOSTNAME=${cfg.server.hostname}"
363
394
];
364
-
EnvironmentFile = config.services.tangled-knotserver.server.secretFile;
395
+
EnvironmentFile = cfg.server.secretFile;
365
396
ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver";
366
397
Restart = "always";
367
398
};
368
399
};
369
400
370
-
networking.firewall.allowedTCPPorts = [22];
401
+
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22];
371
402
};
372
403
};
373
404
···
384
415
virtualisation.cores = 2;
385
416
services.getty.autologinUser = "root";
386
417
environment.systemPackages = with pkgs; [curl vim git];
387
-
systemd.tmpfiles.rules = [
388
-
"w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85"
418
+
systemd.tmpfiles.rules = let
419
+
u = config.services.tangled-knotserver.gitUser;
420
+
g = config.services.tangled-knotserver.gitUser;
421
+
in [
422
+
"d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first
423
+
"f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=5b42390da4c6659f34c9a545adebd8af82c4a19960d735f651e3d582623ba9f2"
389
424
];
390
425
services.tangled-knotserver = {
391
426
enable = true;
+1
-1
go.mod
+1
-1
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
109
+
golang.org/x/crypto v0.36.0 // indirect
110
110
golang.org/x/net v0.37.0 // indirect
111
111
golang.org/x/sys v0.31.0 // indirect
112
112
golang.org/x/time v0.5.0 // indirect
+28
-91
input.css
+28
-91
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
}
16
-
@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
-
}
12
+
26
13
@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
-
}
89
-
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
27
}
99
28
100
29
::selection {
101
-
@apply bg-yellow-400;
102
-
@apply text-black;
103
-
@apply bg-opacity-30;
30
+
@apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white;
104
31
}
105
32
106
33
@layer base {
107
34
html {
108
-
letter-spacing: -0.01em;
109
-
word-spacing: -0.07em;
110
-
font-size: 14px;
35
+
font-size: 15px;
36
+
}
37
+
@supports (font-variation-settings: normal) {
38
+
html {
39
+
font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'tnum' 1;
40
+
}
111
41
}
42
+
112
43
a {
113
-
@apply no-underline text-black hover:underline hover:text-gray-800;
44
+
@apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300;
114
45
}
115
46
116
47
label {
117
-
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase;
48
+
@apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100;
118
49
}
119
50
input {
120
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
51
+
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
121
52
}
122
53
textarea {
123
-
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3;
54
+
@apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400;
124
55
}
125
56
details summary::-webkit-details-marker {
126
57
display: none;
···
141
72
focus-visible:before:outline-4 focus-visible:before:outline-gray-500
142
73
active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)]
143
74
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200
144
-
disabled:hover:before:bg-white disabled:hover:before:shadow-none;
75
+
disabled:hover:before:bg-white disabled:hover:before:shadow-none
76
+
dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700
77
+
dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700
78
+
dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748]
79
+
dark:focus-visible:before:outline-gray-400
80
+
dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)]
81
+
dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700;
145
82
}
146
83
}
147
84
@layer utilities {
148
85
.error {
149
-
@apply py-1 text-red-400;
86
+
@apply py-1 text-red-400 dark:text-red-300;
150
87
}
151
88
.success {
152
-
@apply py-1 text-gray-900;
89
+
@apply py-1 text-gray-900 dark:text-gray-100;
153
90
}
154
91
}
155
92
}
+91
-24
jetstream/jetstream.go
+91
-24
jetstream/jetstream.go
···
4
4
"context"
5
5
"fmt"
6
6
"log/slog"
7
+
"os"
8
+
"os/signal"
7
9
"sync"
10
+
"syscall"
8
11
"time"
9
12
10
13
"github.com/bluesky-social/jetstream/pkg/client"
···
16
19
type DB interface {
17
20
GetLastTimeUs() (int64, error)
18
21
SaveLastTimeUs(int64) error
19
-
UpdateLastTimeUs(int64) error
20
22
}
21
23
24
+
type Set[T comparable] map[T]struct{}
25
+
22
26
type JetstreamClient struct {
23
27
cfg *client.ClientConfig
24
28
client *client.Client
25
29
ident string
26
30
l *slog.Logger
27
31
32
+
wantedDids Set[string]
28
33
db DB
29
34
waitForDid bool
30
35
mu sync.RWMutex
···
37
42
if did == "" {
38
43
return
39
44
}
45
+
46
+
j.l.Info("adding did to in-memory filter", "did", did)
40
47
j.mu.Lock()
41
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
48
+
j.wantedDids[did] = struct{}{}
42
49
j.mu.Unlock()
43
50
}
44
51
45
-
func (j *JetstreamClient) UpdateDids(dids []string) {
46
-
j.mu.Lock()
47
-
for _, did := range dids {
48
-
if did != "" {
49
-
j.cfg.WantedDids = append(j.cfg.WantedDids, did)
50
-
}
51
-
}
52
-
j.mu.Unlock()
52
+
type processor func(context.Context, *models.Event) error
53
53
54
-
j.cancelMu.Lock()
55
-
if j.cancel != nil {
56
-
j.cancel()
54
+
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
55
+
// empty filter => all dids allowed
56
+
if len(j.wantedDids) == 0 {
57
+
return processFunc
57
58
}
58
-
j.cancelMu.Unlock()
59
+
// since this closure references j.WantedDids; it should auto-update
60
+
// existing instances of the closure when j.WantedDids is mutated
61
+
return func(ctx context.Context, evt *models.Event) error {
62
+
if _, ok := j.wantedDids[evt.Did]; ok {
63
+
return processFunc(ctx, evt)
64
+
} else {
65
+
return nil
66
+
}
67
+
}
59
68
}
60
69
61
70
func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) {
···
66
75
}
67
76
68
77
return &JetstreamClient{
69
-
cfg: cfg,
70
-
ident: ident,
71
-
db: db,
72
-
l: logger,
78
+
cfg: cfg,
79
+
ident: ident,
80
+
db: db,
81
+
l: logger,
82
+
wantedDids: make(map[string]struct{}),
73
83
74
84
// This will make the goroutine in StartJetstream wait until
75
-
// cfg.WantedDids has been populated, typically using UpdateDids.
85
+
// j.wantedDids has been populated, typically using addDids.
76
86
waitForDid: waitForDid,
77
87
}, nil
78
88
}
79
89
80
90
// StartJetstream starts the jetstream client and processes events using the provided processFunc.
81
-
// The caller is responsible for saving the last time_us to the database (just use your db.SaveLastTimeUs).
91
+
// The caller is responsible for saving the last time_us to the database (just use your db.UpdateLastTimeUs).
82
92
func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error {
83
93
logger := j.l
84
94
85
-
sched := sequential.NewScheduler(j.ident, logger, processFunc)
95
+
sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc))
86
96
87
97
client, err := client.NewClient(j.cfg, log.New("jetstream"), sched)
88
98
if err != nil {
···
92
102
93
103
go func() {
94
104
if j.waitForDid {
95
-
for len(j.cfg.WantedDids) == 0 {
105
+
for len(j.wantedDids) == 0 {
96
106
time.Sleep(time.Second)
97
107
}
98
108
}
99
109
logger.Info("done waiting for did")
110
+
111
+
go j.periodicLastTimeSave(ctx)
112
+
j.saveIfKilled(ctx)
113
+
100
114
j.connectAndRead(ctx)
101
115
}()
102
116
···
130
144
}
131
145
}
132
146
147
+
// save cursor periodically
148
+
func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) {
149
+
ticker := time.NewTicker(time.Minute)
150
+
defer ticker.Stop()
151
+
152
+
for {
153
+
select {
154
+
case <-ctx.Done():
155
+
return
156
+
case <-ticker.C:
157
+
j.db.SaveLastTimeUs(time.Now().UnixMicro())
158
+
}
159
+
}
160
+
}
161
+
133
162
func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 {
134
163
l := log.FromContext(ctx)
135
164
lastTimeUs, err := j.db.GetLastTimeUs()
···
142
171
}
143
172
}
144
173
145
-
// If last time is older than a week, start from now
174
+
// If last time is older than 2 days, start from now
146
175
if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 {
147
176
lastTimeUs = time.Now().UnixMicro()
148
177
l.Warn("last time us is older than 2 days; discarding that and starting from now")
149
-
err = j.db.UpdateLastTimeUs(lastTimeUs)
178
+
err = j.db.SaveLastTimeUs(lastTimeUs)
150
179
if err != nil {
151
180
l.Error("failed to save last time us", "error", err)
152
181
}
···
155
184
l.Info("found last time_us", "time_us", lastTimeUs)
156
185
return &lastTimeUs
157
186
}
187
+
188
+
func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context {
189
+
ctxWithCancel, cancel := context.WithCancel(ctx)
190
+
191
+
sigChan := make(chan os.Signal, 1)
192
+
193
+
signal.Notify(sigChan,
194
+
syscall.SIGINT,
195
+
syscall.SIGTERM,
196
+
syscall.SIGQUIT,
197
+
syscall.SIGHUP,
198
+
syscall.SIGKILL,
199
+
syscall.SIGSTOP,
200
+
)
201
+
202
+
go func() {
203
+
sig := <-sigChan
204
+
j.l.Info("Received signal, initiating graceful shutdown", "signal", sig)
205
+
206
+
lastTimeUs := time.Now().UnixMicro()
207
+
if err := j.db.SaveLastTimeUs(lastTimeUs); err != nil {
208
+
j.l.Error("Failed to save last time during shutdown", "error", err)
209
+
}
210
+
j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", lastTimeUs)
211
+
212
+
j.cancelMu.Lock()
213
+
if j.cancel != nil {
214
+
j.cancel()
215
+
}
216
+
j.cancelMu.Unlock()
217
+
218
+
cancel()
219
+
220
+
os.Exit(0)
221
+
}()
222
+
223
+
return ctxWithCancel
224
+
}
+6
-10
knotserver/db/jetstream.go
+6
-10
knotserver/db/jetstream.go
···
1
1
package db
2
2
3
3
func (d *DB) SaveLastTimeUs(lastTimeUs int64) error {
4
-
_, err := d.db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs)
4
+
_, err := d.db.Exec(`
5
+
insert into _jetstream (id, last_time_us)
6
+
values (1, ?)
7
+
on conflict(id) do update set last_time_us = excluded.last_time_us
8
+
`, lastTimeUs)
5
9
return err
6
10
}
7
11
8
-
func (d *DB) UpdateLastTimeUs(lastTimeUs int64) error {
9
-
_, err := d.db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs)
10
-
if err != nil {
11
-
return err
12
-
}
13
-
return nil
14
-
}
15
-
16
12
func (d *DB) GetLastTimeUs() (int64, error) {
17
13
var lastTimeUs int64
18
-
row := d.db.QueryRow(`select last_time_us from _jetstream`)
14
+
row := d.db.QueryRow(`select last_time_us from _jetstream where id = 1;`)
19
15
err := row.Scan(&lastTimeUs)
20
16
return lastTimeUs, err
21
17
}
+2
-2
knotserver/db/pubkeys.go
+2
-2
knotserver/db/pubkeys.go
+81
-10
knotserver/git/diff.go
+81
-10
knotserver/git/diff.go
···
6
6
"strings"
7
7
8
8
"github.com/bluekeyes/go-gitdiff/gitdiff"
9
+
"github.com/go-git/go-git/v5/plumbing"
9
10
"github.com/go-git/go-git/v5/plumbing/object"
10
11
"tangled.sh/tangled.sh/core/types"
11
12
)
···
46
47
}
47
48
48
49
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
50
for _, d := range diffs {
60
51
ndiff := types.Diff{}
61
52
ndiff.Name.New = d.NewName
···
82
73
}
83
74
84
75
nd.Stat.FilesChanged = len(diffs)
76
+
nd.Commit.This = c.Hash.String()
77
+
78
+
if parent.Hash.IsZero() {
79
+
nd.Commit.Parent = ""
80
+
} else {
81
+
nd.Commit.Parent = parent.Hash.String()
82
+
}
83
+
nd.Commit.Author = c.Author
84
+
nd.Commit.Message = c.Message
85
85
86
86
return &nd, nil
87
87
}
88
+
89
+
func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) {
90
+
tree1, err := commit1.Tree()
91
+
if err != nil {
92
+
return nil, err
93
+
}
94
+
95
+
tree2, err := commit2.Tree()
96
+
if err != nil {
97
+
return nil, err
98
+
}
99
+
100
+
diff, err := object.DiffTree(tree1, tree2)
101
+
if err != nil {
102
+
return nil, err
103
+
}
104
+
105
+
patch, err := diff.Patch()
106
+
if err != nil {
107
+
return nil, err
108
+
}
109
+
110
+
diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String()))
111
+
if err != nil {
112
+
return nil, err
113
+
}
114
+
115
+
return &types.DiffTree{
116
+
Rev1: commit1.Hash.String(),
117
+
Rev2: commit2.Hash.String(),
118
+
Patch: patch.String(),
119
+
Diff: diffs,
120
+
}, nil
121
+
}
122
+
123
+
func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) {
124
+
isAncestor, err := commit1.IsAncestor(commit2)
125
+
if err != nil {
126
+
return nil, err
127
+
}
128
+
129
+
if isAncestor {
130
+
return commit1, nil
131
+
}
132
+
133
+
mergeBase, err := commit1.MergeBase(commit2)
134
+
if err != nil {
135
+
return nil, err
136
+
}
137
+
138
+
if len(mergeBase) == 0 {
139
+
return nil, fmt.Errorf("failed to find a merge-base")
140
+
}
141
+
142
+
return mergeBase[0], nil
143
+
}
144
+
145
+
func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) {
146
+
rev, err := g.r.ResolveRevision(plumbing.Revision(revStr))
147
+
if err != nil {
148
+
return nil, fmt.Errorf("resolving revision %s: %w", revStr, err)
149
+
}
150
+
151
+
commit, err := g.r.CommitObject(*rev)
152
+
if err != nil {
153
+
154
+
return nil, fmt.Errorf("getting commit for %s: %w", revStr, err)
155
+
}
156
+
157
+
return commit, nil
158
+
}
+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
+
}
+29
-1
knotserver/git/git.go
+29
-1
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 {
···
228
238
return branches, nil
229
239
}
230
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)
257
+
}
258
+
231
259
func (g *GitRepo) FindMainBranch() (string, error) {
232
260
ref, err := g.r.Head()
233
261
if err != nil {
···
308
336
}
309
337
cacheMu.RUnlock()
310
338
311
-
cmd := exec.Command("git", "-C", g.path, "log", "-1", "--format=%H %ct", "--", path)
339
+
cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path)
312
340
313
341
var out bytes.Buffer
314
342
cmd.Stdout = &out
+31
-24
knotserver/git/service/service.go
+31
-24
knotserver/git/service/service.go
···
8
8
"net/http"
9
9
"os/exec"
10
10
"strings"
11
+
"sync"
11
12
"syscall"
12
13
)
13
14
···
68
69
}
69
70
70
71
func (c *ServiceCommand) UploadPack() error {
71
-
cmd := exec.Command("git", []string{
72
-
"-c", "uploadpack.allowFilter=true",
73
-
"upload-pack",
74
-
"--stateless-rpc",
75
-
".",
76
-
}...)
72
+
var stderr bytes.Buffer
73
+
74
+
cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true",
75
+
"upload-pack", "--stateless-rpc", ".")
77
76
cmd.Dir = c.Dir
78
77
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
79
78
80
-
stdoutPipe, _ := cmd.StdoutPipe()
81
-
cmd.Stderr = cmd.Stdout
82
-
defer stdoutPipe.Close()
79
+
stdoutPipe, err := cmd.StdoutPipe()
80
+
if err != nil {
81
+
return fmt.Errorf("failed to create stdout pipe: %w", err)
82
+
}
83
+
84
+
cmd.Stderr = &stderr
83
85
84
86
stdinPipe, err := cmd.StdinPipe()
85
87
if err != nil {
86
-
return err
88
+
return fmt.Errorf("failed to create stdin pipe: %w", err)
87
89
}
88
-
defer stdinPipe.Close()
89
90
90
91
if err := cmd.Start(); err != nil {
91
-
log.Printf("git: failed to start git-upload-pack: %s", err)
92
-
return err
92
+
return fmt.Errorf("failed to start git-upload-pack: %w", err)
93
93
}
94
94
95
-
if _, err := io.Copy(stdinPipe, c.Stdin); err != nil {
96
-
log.Printf("git: failed to copy stdin: %s", err)
97
-
return err
98
-
}
99
-
stdinPipe.Close()
95
+
var wg sync.WaitGroup
96
+
97
+
wg.Add(1)
98
+
go func() {
99
+
defer wg.Done()
100
+
defer stdinPipe.Close()
101
+
io.Copy(stdinPipe, c.Stdin)
102
+
}()
103
+
104
+
wg.Add(1)
105
+
go func() {
106
+
defer wg.Done()
107
+
io.Copy(newWriteFlusher(c.Stdout), stdoutPipe)
108
+
stdoutPipe.Close()
109
+
}()
110
+
111
+
wg.Wait()
100
112
101
-
if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil {
102
-
log.Printf("git: failed to copy stdout: %s", err)
103
-
return err
104
-
}
105
113
if err := cmd.Wait(); err != nil {
106
-
log.Printf("git: failed to wait for git-upload-pack: %s", err)
107
-
return err
114
+
return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
108
115
}
109
116
110
117
return nil
+8
-1
knotserver/git/tree.go
+8
-1
knotserver/git/tree.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"time"
5
6
6
7
"github.com/go-git/go-git/v5/plumbing/object"
7
8
"tangled.sh/tangled.sh/core/types"
···
56
57
lastCommit, err := g.LastCommitForPath(fpath)
57
58
if err != nil {
58
59
fmt.Println("error getting last commit time:", err)
59
-
continue
60
+
// We don't want to skip the file, so worst case lets just
61
+
// populate it with "defaults".
62
+
lastCommit = &types.LastCommitInfo{
63
+
Hash: g.h,
64
+
Message: "",
65
+
When: time.Now(),
66
+
}
60
67
}
61
68
62
69
nts = append(nts, types.NiceTree{
+22
-17
knotserver/git.go
+22
-17
knotserver/git.go
···
34
34
func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) {
35
35
did := chi.URLParam(r, "did")
36
36
name := chi.URLParam(r, "name")
37
-
repo, _ := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
38
-
39
-
w.Header().Set("content-type", "application/x-git-upload-pack-result")
40
-
w.Header().Set("Connection", "Keep-Alive")
41
-
w.Header().Set("Transfer-Encoding", "chunked")
42
-
w.WriteHeader(http.StatusOK)
43
-
44
-
cmd := service.ServiceCommand{
45
-
Dir: repo,
46
-
Stdout: w,
37
+
repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name))
38
+
if err != nil {
39
+
writeError(w, err.Error(), 500)
40
+
d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
41
+
return
47
42
}
48
43
49
-
var reader io.ReadCloser
50
-
reader = r.Body
51
-
44
+
var bodyReader io.ReadCloser = r.Body
52
45
if r.Header.Get("Content-Encoding") == "gzip" {
53
-
reader, err := gzip.NewReader(r.Body)
46
+
gzipReader, err := gzip.NewReader(r.Body)
54
47
if err != nil {
55
48
writeError(w, err.Error(), 500)
56
49
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
57
50
return
58
51
}
59
-
defer reader.Close()
52
+
defer gzipReader.Close()
53
+
bodyReader = gzipReader
54
+
}
55
+
56
+
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
57
+
w.Header().Set("Connection", "Keep-Alive")
58
+
59
+
d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
60
+
61
+
cmd := service.ServiceCommand{
62
+
Dir: repo,
63
+
Stdout: w,
64
+
Stdin: bodyReader,
60
65
}
61
66
62
-
cmd.Stdin = reader
67
+
w.WriteHeader(http.StatusOK)
68
+
63
69
if err := cmd.UploadPack(); err != nil {
64
-
writeError(w, err.Error(), 500)
65
70
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
66
71
return
67
72
}
+17
-2
knotserver/handler.go
+17
-2
knotserver/handler.go
···
60
60
if err != nil {
61
61
return nil, fmt.Errorf("failed to get all Dids: %w", err)
62
62
}
63
+
63
64
if len(dids) > 0 {
64
65
h.knotInitialized = true
65
66
close(h.init)
66
-
// h.jc.UpdateDids(dids)
67
+
for _, d := range dids {
68
+
h.jc.AddDid(d)
69
+
}
67
70
}
68
71
69
72
r.Get("/", h.Index)
73
+
r.Get("/capabilities", h.Capabilities)
70
74
r.Get("/version", h.Version)
71
75
r.Route("/{did}", func(r chi.Router) {
72
76
// Repo routes
···
79
83
r.Get("/", h.RepoIndex)
80
84
r.Get("/info/refs", h.InfoRefs)
81
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)
82
89
83
90
r.Route("/merge", func(r chi.Router) {
84
91
r.With(h.VerifySignature)
···
99
106
r.Get("/archive/{file}", h.Archive)
100
107
r.Get("/commit/{ref}", h.Diff)
101
108
r.Get("/tags", h.Tags)
102
-
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
+
})
103
117
})
104
118
})
105
119
···
108
122
r.Use(h.VerifySignature)
109
123
r.Put("/new", h.NewRepo)
110
124
r.Delete("/", h.RemoveRepo)
125
+
r.Post("/fork", h.RepoFork)
111
126
})
112
127
113
128
r.Route("/member", func(r chi.Router) {
+2
-2
knotserver/jetstream.go
+2
-2
knotserver/jetstream.go
···
53
53
l.Error("failed to add did", "error", err)
54
54
return fmt.Errorf("failed to add did: %w", err)
55
55
}
56
+
h.jc.AddDid(did)
56
57
57
58
if err := h.fetchAndAddKeys(ctx, did); err != nil {
58
59
return fmt.Errorf("failed to fetch and add keys: %w", err)
···
115
116
eventTime := event.TimeUS
116
117
lastTimeUs := eventTime + 1
117
118
fmt.Println("lastTimeUs", lastTimeUs)
118
-
if err := h.db.UpdateLastTimeUs(lastTimeUs); err != nil {
119
+
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
119
120
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
120
121
}
121
-
// h.jc.UpdateDids([]string{did})
122
122
}()
123
123
124
124
raw := json.RawMessage(event.Commit.Record)
+227
-3
knotserver/routes.go
+227
-3
knotserver/routes.go
···
31
31
w.Write([]byte("This is a knot server. More info at https://tangled.sh"))
32
32
}
33
33
34
+
func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) {
35
+
w.Header().Set("Content-Type", "application/json")
36
+
37
+
capabilities := map[string]any{
38
+
"pull_requests": map[string]any{
39
+
"patch_submissions": true,
40
+
"branch_submissions": true,
41
+
"fork_submissions": true,
42
+
},
43
+
}
44
+
45
+
jsonData, err := json.Marshal(capabilities)
46
+
if err != nil {
47
+
http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError)
48
+
return
49
+
}
50
+
51
+
w.Write(jsonData)
52
+
}
53
+
34
54
func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) {
35
55
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
36
56
l := h.l.With("path", path, "handler", "RepoIndex")
···
436
456
return
437
457
}
438
458
459
+
func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) {
460
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
461
+
branchName := chi.URLParam(r, "branch")
462
+
l := h.l.With("handler", "Branch")
463
+
464
+
gr, err := git.PlainOpen(path)
465
+
if err != nil {
466
+
notFound(w)
467
+
return
468
+
}
469
+
470
+
ref, err := gr.Branch(branchName)
471
+
if err != nil {
472
+
l.Error("getting branches", "error", err.Error())
473
+
writeError(w, err.Error(), http.StatusInternalServerError)
474
+
return
475
+
}
476
+
477
+
resp := types.RepoBranchResponse{
478
+
Branch: types.Branch{
479
+
Reference: types.Reference{
480
+
Name: ref.Name().Short(),
481
+
Hash: ref.Hash().String(),
482
+
},
483
+
},
484
+
}
485
+
486
+
writeJSON(w, resp)
487
+
return
488
+
}
489
+
439
490
func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) {
440
491
l := h.l.With("handler", "Keys")
441
492
···
448
499
return
449
500
}
450
501
451
-
data := make([]map[string]interface{}, 0)
502
+
data := make([]map[string]any, 0)
452
503
for _, key := range keys {
453
504
j := key.JSON()
454
505
data = append(data, j)
···
526
577
w.WriteHeader(http.StatusNoContent)
527
578
}
528
579
580
+
func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
581
+
l := h.l.With("handler", "RepoFork")
582
+
583
+
data := struct {
584
+
Did string `json:"did"`
585
+
Source string `json:"source"`
586
+
Name string `json:"name,omitempty"`
587
+
}{}
588
+
589
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
590
+
writeError(w, "invalid request body", http.StatusBadRequest)
591
+
return
592
+
}
593
+
594
+
did := data.Did
595
+
source := data.Source
596
+
597
+
if did == "" || source == "" {
598
+
l.Error("invalid request body, empty did or name")
599
+
w.WriteHeader(http.StatusBadRequest)
600
+
return
601
+
}
602
+
603
+
var name string
604
+
if data.Name != "" {
605
+
name = data.Name
606
+
} else {
607
+
name = filepath.Base(source)
608
+
}
609
+
610
+
relativeRepoPath := filepath.Join(did, name)
611
+
repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
612
+
613
+
err := git.Fork(repoPath, source)
614
+
if err != nil {
615
+
l.Error("forking repo", "error", err.Error())
616
+
writeError(w, err.Error(), http.StatusInternalServerError)
617
+
return
618
+
}
619
+
620
+
// add perms for this user to access the repo
621
+
err = h.e.AddRepo(did, ThisServer, relativeRepoPath)
622
+
if err != nil {
623
+
l.Error("adding repo permissions", "error", err.Error())
624
+
writeError(w, err.Error(), http.StatusInternalServerError)
625
+
return
626
+
}
627
+
628
+
w.WriteHeader(http.StatusNoContent)
629
+
}
630
+
529
631
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
530
632
l := h.l.With("handler", "RemoveRepo")
531
633
···
665
767
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
666
768
}
667
769
770
+
func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) {
771
+
rev1 := chi.URLParam(r, "rev1")
772
+
rev1, _ = url.PathUnescape(rev1)
773
+
774
+
rev2 := chi.URLParam(r, "rev2")
775
+
rev2, _ = url.PathUnescape(rev2)
776
+
777
+
l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2)
778
+
779
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
780
+
gr, err := git.PlainOpen(path)
781
+
if err != nil {
782
+
notFound(w)
783
+
return
784
+
}
785
+
786
+
commit1, err := gr.ResolveRevision(rev1)
787
+
if err != nil {
788
+
l.Error("error resolving revision 1", "msg", err.Error())
789
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest)
790
+
return
791
+
}
792
+
793
+
commit2, err := gr.ResolveRevision(rev2)
794
+
if err != nil {
795
+
l.Error("error resolving revision 2", "msg", err.Error())
796
+
writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest)
797
+
return
798
+
}
799
+
800
+
mergeBase, err := gr.MergeBase(commit1, commit2)
801
+
if err != nil {
802
+
l.Error("failed to find merge-base", "msg", err.Error())
803
+
writeError(w, "failed to calculate diff", http.StatusBadRequest)
804
+
return
805
+
}
806
+
807
+
difftree, err := gr.DiffTree(mergeBase, commit2)
808
+
if err != nil {
809
+
l.Error("error comparing revisions", "msg", err.Error())
810
+
writeError(w, "error comparing revisions", http.StatusBadRequest)
811
+
return
812
+
}
813
+
814
+
writeJSON(w, types.RepoDiffTreeResponse{difftree})
815
+
return
816
+
}
817
+
818
+
func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) {
819
+
l := h.l.With("handler", "NewHiddenRef")
820
+
821
+
forkRef := chi.URLParam(r, "forkRef")
822
+
remoteRef := chi.URLParam(r, "remoteRef")
823
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
824
+
gr, err := git.PlainOpen(path)
825
+
if err != nil {
826
+
notFound(w)
827
+
return
828
+
}
829
+
830
+
err = gr.TrackHiddenRemoteRef(forkRef, remoteRef)
831
+
if err != nil {
832
+
l.Error("error tracking hidden remote ref", "msg", err.Error())
833
+
writeError(w, "error tracking hidden remote ref", http.StatusBadRequest)
834
+
return
835
+
}
836
+
837
+
w.WriteHeader(http.StatusNoContent)
838
+
return
839
+
}
840
+
668
841
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
669
842
l := h.l.With("handler", "AddMember")
670
843
···
684
857
writeError(w, err.Error(), http.StatusInternalServerError)
685
858
return
686
859
}
687
-
688
860
h.jc.AddDid(did)
861
+
689
862
if err := h.e.AddMember(ThisServer, did); err != nil {
690
863
l.Error("adding member", "error", err.Error())
691
864
writeError(w, err.Error(), http.StatusInternalServerError)
···
739
912
w.WriteHeader(http.StatusNoContent)
740
913
}
741
914
915
+
func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) {
916
+
l := h.l.With("handler", "DefaultBranch")
917
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
918
+
919
+
gr, err := git.Open(path, "")
920
+
if err != nil {
921
+
notFound(w)
922
+
return
923
+
}
924
+
925
+
branch, err := gr.FindMainBranch()
926
+
if err != nil {
927
+
writeError(w, err.Error(), http.StatusInternalServerError)
928
+
l.Error("getting default branch", "error", err.Error())
929
+
return
930
+
}
931
+
932
+
writeJSON(w, types.RepoDefaultBranchResponse{
933
+
Branch: branch,
934
+
})
935
+
}
936
+
937
+
func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
938
+
l := h.l.With("handler", "SetDefaultBranch")
939
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
940
+
941
+
data := struct {
942
+
Branch string `json:"branch"`
943
+
}{}
944
+
945
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
946
+
writeError(w, err.Error(), http.StatusBadRequest)
947
+
return
948
+
}
949
+
950
+
gr, err := git.Open(path, "")
951
+
if err != nil {
952
+
notFound(w)
953
+
return
954
+
}
955
+
956
+
err = gr.SetDefaultBranch(data.Branch)
957
+
if err != nil {
958
+
writeError(w, err.Error(), http.StatusInternalServerError)
959
+
l.Error("setting default branch", "error", err.Error())
960
+
return
961
+
}
962
+
963
+
w.WriteHeader(http.StatusNoContent)
964
+
}
965
+
742
966
func (h *Handle) Init(w http.ResponseWriter, r *http.Request) {
743
967
l := h.l.With("handler", "Init")
744
968
···
768
992
writeError(w, err.Error(), http.StatusInternalServerError)
769
993
return
770
994
}
995
+
h.jc.AddDid(data.Did)
771
996
772
-
// h.jc.UpdateDids([]string{data.Did})
773
997
if err := h.e.AddOwner(ThisServer, data.Did); err != nil {
774
998
l.Error("adding owner", "error", err.Error())
775
999
writeError(w, err.Error(), http.StatusInternalServerError)
+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
+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!
+43
-9
tailwind.config.js
+43
-9
tailwind.config.js
···
1
1
/** @type {import('tailwindcss').Config} */
2
-
const colors = require('tailwindcss/colors')
2
+
const colors = require("tailwindcss/colors");
3
3
4
4
module.exports = {
5
-
content: ["./appview/pages/templates/**/*.html"],
5
+
content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"],
6
+
darkMode: "media",
6
7
theme: {
7
8
container: {
8
9
padding: "2rem",
···
12
13
md: "600px",
13
14
lg: "800px",
14
15
xl: "1000px",
15
-
"2xl": "1200px"
16
+
"2xl": "1200px",
16
17
},
17
18
},
18
19
extend: {
19
20
fontFamily: {
20
-
sans: ["iA Writer Quattro S", "Inter", "system-ui", "sans-serif", "ui-sans-serif"],
21
-
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
+
],
22
33
},
23
34
typography: {
24
35
DEFAULT: {
25
36
css: {
26
-
maxWidth: 'none',
37
+
maxWidth: "none",
27
38
pre: {
28
39
backgroundColor: colors.gray[100],
29
40
color: colors.black,
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": {}
30
66
},
31
67
},
32
68
},
33
69
},
34
70
},
35
71
},
36
-
plugins: [
37
-
require('@tailwindcss/typography'),
38
-
]
72
+
plugins: [require("@tailwindcss/typography")],
39
73
};
+9
types/capabilities.go
+9
types/capabilities.go
+21
types/diff.go
+21
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
+
}
+12
types/repo.go
+12
types/repo.go
···
32
32
Diff *NiceDiff `json:"diff,omitempty"`
33
33
}
34
34
35
+
type RepoDiffTreeResponse struct {
36
+
DiffTree *DiffTree `json:"difftree,omitempty"`
37
+
}
38
+
35
39
type RepoTreeResponse struct {
36
40
Ref string `json:"ref,omitempty"`
37
41
Parent string `json:"parent,omitempty"`
···
61
65
62
66
type RepoBranchesResponse struct {
63
67
Branches []Branch `json:"branches,omitempty"`
68
+
}
69
+
70
+
type RepoBranchResponse struct {
71
+
Branch Branch `json:"branch,omitempty"`
72
+
}
73
+
74
+
type RepoDefaultBranchResponse struct {
75
+
Branch string `json:"branch,omitempty"`
64
76
}
65
77
66
78
type RepoBlobResponse struct {