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

Compare changes

Choose any two refs to compare.

Changed files
+9477 -2787
.air
api
appview
cmd
combinediff
interdiff
jstest
knotserver
docker
rootfs
etc
s6-overlay
s6-rc.d
create-sshd-host-keys
knotserver
dependencies.d
run
sshd
user
contents.d
scripts
ssh
sshd_config.d
docs
jetstream
knotserver
lexicons
patchutil
rbac
types
+1 -1
.air/knotserver.toml
··· 1 [build] 2 - cmd = "go build -o .bin/knot ./cmd/knotserver/main.go" 3 bin = ".bin/knot" 4 root = "." 5
··· 1 [build] 2 + cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knotserver/main.go' 3 bin = ".bin/knot" 4 root = "." 5
+246 -45
api/tangled/cbor_gen.go
··· 1753 } 1754 1755 cw := cbg.NewCborWriter(w) 1756 - fieldCount := 6 1757 1758 if t.AddedAt == nil { 1759 fieldCount-- 1760 } 1761 1762 if t.Description == nil { 1763 fieldCount-- 1764 } 1765 ··· 1855 return err 1856 } 1857 1858 // t.AddedAt (string) (string) 1859 if t.AddedAt != nil { 1860 ··· 2006 2007 t.Owner = string(sval) 2008 } 2009 // t.AddedAt (string) (string) 2010 case "addedAt": 2011 ··· 2076 fieldCount-- 2077 } 2078 2079 - if t.SourceRepo == nil { 2080 fieldCount-- 2081 } 2082 ··· 2203 } 2204 } 2205 2206 - // t.CreatedAt (string) (string) 2207 - if t.CreatedAt != nil { 2208 2209 - if len("createdAt") > 1000000 { 2210 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2211 } 2212 2213 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2214 return err 2215 } 2216 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2217 return err 2218 } 2219 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 - } 2235 } 2236 } 2237 2238 - // t.SourceRepo (string) (string) 2239 - if t.SourceRepo != nil { 2240 2241 - if len("sourceRepo") > 1000000 { 2242 - return xerrors.Errorf("Value in field \"sourceRepo\" was too long") 2243 } 2244 2245 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { 2246 return err 2247 } 2248 - if _, err := cw.WriteString(string("sourceRepo")); err != nil { 2249 return err 2250 } 2251 2252 - if t.SourceRepo == nil { 2253 if _, err := cw.Write(cbg.CborNull); err != nil { 2254 return err 2255 } 2256 } else { 2257 - if len(*t.SourceRepo) > 1000000 { 2258 - return xerrors.Errorf("Value in field t.SourceRepo was too long") 2259 } 2260 2261 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { 2262 return err 2263 } 2264 - if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { 2265 return err 2266 } 2267 } ··· 2436 2437 t.PullId = int64(extraI) 2438 } 2439 - // t.CreatedAt (string) (string) 2440 - case "createdAt": 2441 2442 { 2443 b, err := cr.ReadByte() 2444 if err != nil { 2445 return err ··· 2448 if err := cr.UnreadByte(); err != nil { 2449 return err 2450 } 2451 - 2452 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2453 - if err != nil { 2454 - return err 2455 } 2456 - 2457 - t.CreatedAt = (*string)(&sval) 2458 } 2459 } 2460 - // t.SourceRepo (string) (string) 2461 - case "sourceRepo": 2462 2463 { 2464 b, err := cr.ReadByte() ··· 2475 return err 2476 } 2477 2478 - t.SourceRepo = (*string)(&sval) 2479 } 2480 } 2481 // t.TargetRepo (string) (string) ··· 2499 } 2500 2501 t.TargetBranch = string(sval) 2502 } 2503 2504 default:
··· 1753 } 1754 1755 cw := cbg.NewCborWriter(w) 1756 + fieldCount := 7 1757 1758 if t.AddedAt == nil { 1759 fieldCount-- 1760 } 1761 1762 if t.Description == nil { 1763 + fieldCount-- 1764 + } 1765 + 1766 + if t.Source == nil { 1767 fieldCount-- 1768 } 1769 ··· 1859 return err 1860 } 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 + 1894 // t.AddedAt (string) (string) 1895 if t.AddedAt != nil { 1896 ··· 2042 2043 t.Owner = string(sval) 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 + } 2066 // t.AddedAt (string) (string) 2067 case "addedAt": 2068 ··· 2133 fieldCount-- 2134 } 2135 2136 + if t.Source == nil { 2137 fieldCount-- 2138 } 2139 ··· 2260 } 2261 } 2262 2263 + // t.Source (tangled.RepoPull_Source) (struct) 2264 + if t.Source != nil { 2265 2266 + if len("source") > 1000000 { 2267 + return xerrors.Errorf("Value in field \"source\" was too long") 2268 } 2269 2270 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil { 2271 return err 2272 } 2273 + if _, err := cw.WriteString(string("source")); err != nil { 2274 return err 2275 } 2276 2277 + if err := t.Source.MarshalCBOR(cw); err != nil { 2278 + return err 2279 } 2280 } 2281 2282 + // t.CreatedAt (string) (string) 2283 + if t.CreatedAt != nil { 2284 2285 + if len("createdAt") > 1000000 { 2286 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2287 } 2288 2289 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2290 return err 2291 } 2292 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2293 return err 2294 } 2295 2296 + if t.CreatedAt == nil { 2297 if _, err := cw.Write(cbg.CborNull); err != nil { 2298 return err 2299 } 2300 } else { 2301 + if len(*t.CreatedAt) > 1000000 { 2302 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2303 } 2304 2305 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil { 2306 return err 2307 } 2308 + if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil { 2309 return err 2310 } 2311 } ··· 2480 2481 t.PullId = int64(extraI) 2482 } 2483 + // t.Source (tangled.RepoPull_Source) (struct) 2484 + case "source": 2485 2486 { 2487 + 2488 b, err := cr.ReadByte() 2489 if err != nil { 2490 return err ··· 2493 if err := cr.UnreadByte(); err != nil { 2494 return err 2495 } 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) 2499 } 2500 } 2501 + 2502 } 2503 + // t.CreatedAt (string) (string) 2504 + case "createdAt": 2505 2506 { 2507 b, err := cr.ReadByte() ··· 2518 return err 2519 } 2520 2521 + t.CreatedAt = (*string)(&sval) 2522 } 2523 } 2524 // t.TargetRepo (string) (string) ··· 2542 } 2543 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) 2703 } 2704 2705 default:
+15 -9
api/tangled/repopull.go
··· 17 } // 18 // RECORDTYPE: RepoPull 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"` 29 }
··· 17 } // 18 // RECORDTYPE: RepoPull 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 + 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"` 35 }
+2
api/tangled/tangledrepo.go
··· 25 // name: name of the repo 26 Name string `json:"name" cborgen:"name"` 27 Owner string `json:"owner" cborgen:"owner"` 28 }
··· 25 // name: name of the repo 26 Name string `json:"name" cborgen:"name"` 27 Owner string `json:"owner" cborgen:"owner"` 28 + // source: source of the repo 29 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 }
+7 -1
appview/auth/auth.go
··· 128 } 129 130 func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error { 131 - clientSession, _ := a.Store.Get(r, appview.SessionName) 132 clientSession.Options.MaxAge = -1 133 return clientSession.Save(r, w) 134 }
··· 128 } 129 130 func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error { 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 + } 138 clientSession.Options.MaxAge = -1 139 return clientSession.Save(r, w) 140 }
+32
appview/db/db.go
··· 248 return nil 249 }) 250 251 return &DB{db}, nil 252 } 253
··· 248 return nil 249 }) 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 + 283 return &DB{db}, nil 284 } 285
+237 -24
appview/db/issues.go
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 9 10 type Issue struct { ··· 12 OwnerDid string 13 IssueId int 14 IssueAt string 15 - Created *time.Time 16 Title string 17 Body string 18 Open bool 19 Metadata *IssueMetadata 20 } 21 22 type IssueMetadata struct { 23 CommentCount int 24 // labels, assignee etc. 25 } 26 27 type Comment struct { 28 OwnerDid string 29 RepoAt syntax.ATURI 30 - CommentAt string 31 Issue int 32 CommentId int 33 Body string 34 Created *time.Time 35 } 36 37 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 96 return ownerDid, err 97 } 98 99 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) { 100 var issues []Issue 101 openValue := 0 102 if isOpen { ··· 104 } 105 106 rows, err := e.Query( 107 `select 108 i.owner_did, 109 i.issue_id, 110 i.created, 111 i.title, 112 i.body, 113 i.open, 114 - count(c.id) 115 from 116 issues i 117 - left join 118 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 119 - where 120 - i.repo_at = ? and i.open = ? 121 - group by 122 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 123 order by 124 i.created desc`, 125 - repoAt, openValue) 126 if err != nil { 127 return nil, err 128 } ··· 130 131 for rows.Next() { 132 var issue Issue 133 - var createdAt string 134 - var metadata IssueMetadata 135 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 136 if err != nil { 137 return nil, err 138 } 139 140 - createdTime, err := time.Parse(time.RFC3339, createdAt) 141 if err != nil { 142 return nil, err 143 } 144 - issue.Created = &createdTime 145 - issue.Metadata = &metadata 146 147 issues = append(issues, issue) 148 } ··· 169 if err != nil { 170 return nil, err 171 } 172 - issue.Created = &createdTime 173 174 return &issue, nil 175 } ··· 189 if err != nil { 190 return nil, nil, err 191 } 192 - issue.Created = &createdTime 193 194 comments, err := GetComments(e, repoAt, issueId) 195 if err != nil { ··· 199 return &issue, comments, nil 200 } 201 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 (?, ?, ?, ?, ?, ?)` 204 _, err := e.Exec( 205 query, 206 comment.OwnerDid, 207 comment.RepoAt, 208 - comment.CommentAt, 209 comment.Issue, 210 comment.CommentId, 211 comment.Body, ··· 216 func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 217 var comments []Comment 218 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) 220 if err == sql.ErrNoRows { 221 return []Comment{}, nil 222 } ··· 228 for rows.Next() { 229 var comment Comment 230 var createdAt string 231 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt) 232 if err != nil { 233 return nil, err 234 } ··· 239 } 240 comment.Created = &createdAtTime 241 242 comments = append(comments, comment) 243 } 244 ··· 247 } 248 249 return comments, nil 250 } 251 252 func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 11 type Issue struct { ··· 13 OwnerDid string 14 IssueId int 15 IssueAt string 16 + Created time.Time 17 Title string 18 Body string 19 Open bool 20 + 21 + // optionally, populate this when querying for reverse mappings 22 + // like comment counts, parent repo etc. 23 Metadata *IssueMetadata 24 } 25 26 type IssueMetadata struct { 27 CommentCount int 28 + Repo *Repo 29 // labels, assignee etc. 30 } 31 32 type Comment struct { 33 OwnerDid string 34 RepoAt syntax.ATURI 35 + Rkey string 36 Issue int 37 CommentId int 38 Body string 39 Created *time.Time 40 + Deleted *time.Time 41 + Edited *time.Time 42 } 43 44 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 103 return ownerDid, err 104 } 105 106 + func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 107 var issues []Issue 108 openValue := 0 109 if isOpen { ··· 111 } 112 113 rows, err := e.Query( 114 + ` 115 + with numbered_issue as ( 116 + select 117 + i.owner_did, 118 + i.issue_id, 119 + i.created, 120 + i.title, 121 + i.body, 122 + i.open, 123 + count(c.id) as comment_count, 124 + row_number() over (order by i.created desc) as row_num 125 + from 126 + issues i 127 + left join 128 + comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 129 + where 130 + i.repo_at = ? and i.open = ? 131 + group by 132 + i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 + ) 134 + select 135 + owner_did, 136 + issue_id, 137 + created, 138 + title, 139 + body, 140 + open, 141 + comment_count 142 + from 143 + numbered_issue 144 + where 145 + row_num between ? and ?`, 146 + repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 147 + if err != nil { 148 + return nil, err 149 + } 150 + defer rows.Close() 151 + 152 + for rows.Next() { 153 + var issue Issue 154 + var createdAt string 155 + var metadata IssueMetadata 156 + err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 + if err != nil { 158 + return nil, err 159 + } 160 + 161 + createdTime, err := time.Parse(time.RFC3339, createdAt) 162 + if err != nil { 163 + return nil, err 164 + } 165 + issue.Created = createdTime 166 + issue.Metadata = &metadata 167 + 168 + issues = append(issues, issue) 169 + } 170 + 171 + if err := rows.Err(); err != nil { 172 + return nil, err 173 + } 174 + 175 + return issues, nil 176 + } 177 + 178 + // timeframe here is directly passed into the sql query filter, and any 179 + // timeframe in the past should be negative; e.g.: "-3 months" 180 + func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 181 + var issues []Issue 182 + 183 + rows, err := e.Query( 184 `select 185 i.owner_did, 186 + i.repo_at, 187 i.issue_id, 188 i.created, 189 i.title, 190 i.body, 191 i.open, 192 + r.did, 193 + r.name, 194 + r.knot, 195 + r.rkey, 196 + r.created 197 from 198 issues i 199 + join 200 + repos r on i.repo_at = r.at_uri 201 + where 202 + i.owner_did = ? and i.created >= date ('now', ?) 203 order by 204 i.created desc`, 205 + ownerDid, timeframe) 206 if err != nil { 207 return nil, err 208 } ··· 210 211 for rows.Next() { 212 var issue Issue 213 + var issueCreatedAt, repoCreatedAt string 214 + var repo Repo 215 + err := rows.Scan( 216 + &issue.OwnerDid, 217 + &issue.RepoAt, 218 + &issue.IssueId, 219 + &issueCreatedAt, 220 + &issue.Title, 221 + &issue.Body, 222 + &issue.Open, 223 + &repo.Did, 224 + &repo.Name, 225 + &repo.Knot, 226 + &repo.Rkey, 227 + &repoCreatedAt, 228 + ) 229 if err != nil { 230 return nil, err 231 } 232 233 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 234 if err != nil { 235 return nil, err 236 } 237 + issue.Created = issueCreatedTime 238 + 239 + repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 240 + if err != nil { 241 + return nil, err 242 + } 243 + repo.Created = repoCreatedTime 244 + 245 + issue.Metadata = &IssueMetadata{ 246 + Repo: &repo, 247 + } 248 249 issues = append(issues, issue) 250 } ··· 271 if err != nil { 272 return nil, err 273 } 274 + issue.Created = createdTime 275 276 return &issue, nil 277 } ··· 291 if err != nil { 292 return nil, nil, err 293 } 294 + issue.Created = createdTime 295 296 comments, err := GetComments(e, repoAt, issueId) 297 if err != nil { ··· 301 return &issue, comments, nil 302 } 303 304 + func NewIssueComment(e Execer, comment *Comment) error { 305 + query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 306 _, err := e.Exec( 307 query, 308 comment.OwnerDid, 309 comment.RepoAt, 310 + comment.Rkey, 311 comment.Issue, 312 comment.CommentId, 313 comment.Body, ··· 318 func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 319 var comments []Comment 320 321 + rows, err := e.Query(` 322 + select 323 + owner_did, 324 + issue_id, 325 + comment_id, 326 + rkey, 327 + body, 328 + created, 329 + edited, 330 + deleted 331 + from 332 + comments 333 + where 334 + repo_at = ? and issue_id = ? 335 + order by 336 + created asc`, 337 + repoAt, 338 + issueId, 339 + ) 340 if err == sql.ErrNoRows { 341 return []Comment{}, nil 342 } ··· 348 for rows.Next() { 349 var comment Comment 350 var createdAt string 351 + var deletedAt, editedAt, rkey sql.NullString 352 + err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 353 if err != nil { 354 return nil, err 355 } ··· 360 } 361 comment.Created = &createdAtTime 362 363 + if deletedAt.Valid { 364 + deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 365 + if err != nil { 366 + return nil, err 367 + } 368 + comment.Deleted = &deletedTime 369 + } 370 + 371 + if editedAt.Valid { 372 + editedTime, err := time.Parse(time.RFC3339, editedAt.String) 373 + if err != nil { 374 + return nil, err 375 + } 376 + comment.Edited = &editedTime 377 + } 378 + 379 + if rkey.Valid { 380 + comment.Rkey = rkey.String 381 + } 382 + 383 comments = append(comments, comment) 384 } 385 ··· 388 } 389 390 return comments, nil 391 + } 392 + 393 + func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 394 + query := ` 395 + select 396 + owner_did, body, rkey, created, deleted, edited 397 + from 398 + comments where repo_at = ? and issue_id = ? and comment_id = ? 399 + ` 400 + row := e.QueryRow(query, repoAt, issueId, commentId) 401 + 402 + var comment Comment 403 + var createdAt string 404 + var deletedAt, editedAt, rkey sql.NullString 405 + err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 406 + if err != nil { 407 + return nil, err 408 + } 409 + 410 + createdTime, err := time.Parse(time.RFC3339, createdAt) 411 + if err != nil { 412 + return nil, err 413 + } 414 + comment.Created = &createdTime 415 + 416 + if deletedAt.Valid { 417 + deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 418 + if err != nil { 419 + return nil, err 420 + } 421 + comment.Deleted = &deletedTime 422 + } 423 + 424 + if editedAt.Valid { 425 + editedTime, err := time.Parse(time.RFC3339, editedAt.String) 426 + if err != nil { 427 + return nil, err 428 + } 429 + comment.Edited = &editedTime 430 + } 431 + 432 + if rkey.Valid { 433 + comment.Rkey = rkey.String 434 + } 435 + 436 + comment.RepoAt = repoAt 437 + comment.Issue = issueId 438 + comment.CommentId = commentId 439 + 440 + return &comment, nil 441 + } 442 + 443 + func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 444 + _, err := e.Exec( 445 + ` 446 + update comments 447 + set body = ?, 448 + edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 449 + where repo_at = ? and issue_id = ? and comment_id = ? 450 + `, newBody, repoAt, issueId, commentId) 451 + return err 452 + } 453 + 454 + func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 455 + _, err := e.Exec( 456 + ` 457 + update comments 458 + set body = "", 459 + deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 460 + where repo_at = ? and issue_id = ? and comment_id = ? 461 + `, repoAt, issueId, commentId) 462 + return err 463 } 464 465 func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+6 -10
appview/db/jetstream.go
··· 5 } 6 7 func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error { 8 - _, err := db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs) 9 return err 10 } 11 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 func (db DbWrapper) GetLastTimeUs() (int64, error) { 21 var lastTimeUs int64 22 - row := db.QueryRow(`select last_time_us from _jetstream`) 23 err := row.Scan(&lastTimeUs) 24 return lastTimeUs, err 25 }
··· 5 } 6 7 func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error { 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) 13 return err 14 } 15 16 func (db DbWrapper) GetLastTimeUs() (int64, error) { 17 var lastTimeUs int64 18 + row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`) 19 err := row.Scan(&lastTimeUs) 20 return lastTimeUs, err 21 }
+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 + }
+341 -24
appview/db/pulls.go
··· 4 "database/sql" 5 "fmt" 6 "log" 7 "strings" 8 "time" 9 10 "github.com/bluekeyes/go-gitdiff/gitdiff" 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.sh/tangled.sh/core/types" 13 ) 14 ··· 62 Submissions []*PullSubmission 63 64 // meta 65 - Created time.Time 66 } 67 68 type PullSubmission struct { ··· 77 RoundNumber int 78 Patch string 79 Comments []PullComment 80 81 // meta 82 Created time.Time ··· 109 return len(p.Submissions) - 1 110 } 111 112 - func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 113 patch := s.Patch 114 115 - diffs, _, err := gitdiff.Parse(strings.NewReader(patch)) 116 if err != nil { 117 log.Println(err) 118 } ··· 150 return nd 151 } 152 153 func NewPull(tx *sql.Tx, pull *Pull) error { 154 defer tx.Rollback() 155 ··· 175 pull.PullId = nextId 176 pull.State = PullOpen 177 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) 182 if err != nil { 183 return err 184 } 185 186 _, 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) 190 if err != nil { 191 return err 192 } ··· 215 return pullId - 1, err 216 } 217 218 - func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) { 219 - var pulls []Pull 220 221 rows, err := e.Query(` 222 select ··· 228 target_branch, 229 pull_at, 230 body, 231 - rkey 232 from 233 pulls 234 where 235 - repo_at = ? and state = ? 236 - order by 237 - created desc`, repoAt, state) 238 if err != nil { 239 return nil, err 240 } ··· 243 for rows.Next() { 244 var pull Pull 245 var createdAt string 246 err := rows.Scan( 247 &pull.OwnerDid, 248 &pull.PullId, ··· 253 &pull.PullAt, 254 &pull.Body, 255 &pull.Rkey, 256 ) 257 if err != nil { 258 return nil, err ··· 264 } 265 pull.Created = createdTime 266 267 - pulls = append(pulls, pull) 268 } 269 270 if err := rows.Err(); err != nil { 271 return nil, err 272 } 273 274 - return pulls, nil 275 } 276 277 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 286 pull_at, 287 repo_at, 288 body, 289 - rkey 290 from 291 pulls 292 where ··· 296 297 var pull Pull 298 var createdAt string 299 err := row.Scan( 300 &pull.OwnerDid, 301 &pull.PullId, ··· 307 &pull.RepoAt, 308 &pull.Body, 309 &pull.Rkey, 310 ) 311 if err != nil { 312 return nil, err ··· 318 } 319 pull.Created = createdTime 320 321 submissionsQuery := ` 322 select 323 - id, pull_id, repo_at, round_number, patch, created 324 from 325 pull_submissions 326 where ··· 337 for submissionsRows.Next() { 338 var submission PullSubmission 339 var submissionCreatedStr string 340 err := submissionsRows.Scan( 341 &submission.ID, 342 &submission.PullId, ··· 344 &submission.RoundNumber, 345 &submission.Patch, 346 &submissionCreatedStr, 347 ) 348 if err != nil { 349 return nil, err ··· 354 return nil, err 355 } 356 submission.Created = submissionCreatedTime 357 358 submissionsMap[submission.ID] = &submission 359 } ··· 425 return nil, err 426 } 427 428 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 429 for _, submission := range submissionsMap { 430 pull.Submissions[submission.RoundNumber] = submission ··· 433 return &pull, nil 434 } 435 436 func NewPullComment(e Execer, comment *PullComment) (int64, error) { 437 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 438 res, err := e.Exec( ··· 476 return err 477 } 478 479 - func ResubmitPull(e Execer, pull *Pull, newPatch string) error { 480 newRoundNumber := len(pull.Submissions) 481 _, err := e.Exec(` 482 - insert into pull_submissions (pull_id, repo_at, round_number, patch) 483 - values (?, ?, ?, ?) 484 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch) 485 486 return err 487 }
··· 4 "database/sql" 5 "fmt" 6 "log" 7 + "sort" 8 "strings" 9 "time" 10 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 "tangled.sh/tangled.sh/core/types" 15 ) 16 ··· 64 Submissions []*PullSubmission 65 66 // meta 67 + Created time.Time 68 + PullSource *PullSource 69 + 70 + // optionally, populate this when querying for reverse mappings 71 + Repo *Repo 72 + } 73 + 74 + type PullSource struct { 75 + Branch string 76 + RepoAt *syntax.ATURI 77 + 78 + // optionally populate this for reverse mappings 79 + Repo *Repo 80 } 81 82 type PullSubmission struct { ··· 91 RoundNumber int 92 Patch string 93 Comments []PullComment 94 + SourceRev string // include the rev that was used to create this submission: only for branch PRs 95 96 // meta 97 Created time.Time ··· 124 return len(p.Submissions) - 1 125 } 126 127 + func (p *Pull) IsPatchBased() bool { 128 + return p.PullSource == nil 129 + } 130 + 131 + func (p *Pull) IsBranchBased() bool { 132 + if p.PullSource != nil { 133 + if p.PullSource.RepoAt != nil { 134 + return p.PullSource.RepoAt == &p.RepoAt 135 + } else { 136 + // no repo specified 137 + return true 138 + } 139 + } 140 + return false 141 + } 142 + 143 + func (p *Pull) IsForkBased() bool { 144 + if p.PullSource != nil { 145 + if p.PullSource.RepoAt != nil { 146 + // make sure repos are different 147 + return p.PullSource.RepoAt != &p.RepoAt 148 + } 149 + } 150 + return false 151 + } 152 + 153 + func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { 154 patch := s.Patch 155 156 + // if format-patch; then extract each patch 157 + var diffs []*gitdiff.File 158 + if patchutil.IsFormatPatch(patch) { 159 + patches, err := patchutil.ExtractPatches(patch) 160 + if err != nil { 161 + return nil, err 162 + } 163 + var ps [][]*gitdiff.File 164 + for _, p := range patches { 165 + ps = append(ps, p.Files) 166 + } 167 + 168 + diffs = patchutil.CombineDiff(ps...) 169 + } else { 170 + d, _, err := gitdiff.Parse(strings.NewReader(patch)) 171 + if err != nil { 172 + return nil, err 173 + } 174 + diffs = d 175 + } 176 + 177 + return diffs, nil 178 + } 179 + 180 + func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 181 + diffs, err := s.AsDiff(targetBranch) 182 if err != nil { 183 log.Println(err) 184 } ··· 216 return nd 217 } 218 219 + func (s PullSubmission) IsFormatPatch() bool { 220 + return patchutil.IsFormatPatch(s.Patch) 221 + } 222 + 223 + func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch { 224 + patches, err := patchutil.ExtractPatches(s.Patch) 225 + if err != nil { 226 + log.Println("error extracting patches from submission:", err) 227 + return []patchutil.FormatPatch{} 228 + } 229 + 230 + return patches 231 + } 232 + 233 func NewPull(tx *sql.Tx, pull *Pull) error { 234 defer tx.Rollback() 235 ··· 255 pull.PullId = nextId 256 pull.State = PullOpen 257 258 + var sourceBranch, sourceRepoAt *string 259 + if pull.PullSource != nil { 260 + sourceBranch = &pull.PullSource.Branch 261 + if pull.PullSource.RepoAt != nil { 262 + x := pull.PullSource.RepoAt.String() 263 + sourceRepoAt = &x 264 + } 265 + } 266 + 267 + _, err = tx.Exec( 268 + ` 269 + insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at) 270 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 271 + pull.RepoAt, 272 + pull.OwnerDid, 273 + pull.PullId, 274 + pull.Title, 275 + pull.TargetBranch, 276 + pull.Body, 277 + pull.Rkey, 278 + pull.State, 279 + sourceBranch, 280 + sourceRepoAt, 281 + ) 282 if err != nil { 283 return err 284 } 285 286 _, err = tx.Exec(` 287 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 288 + values (?, ?, ?, ?, ?) 289 + `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 290 if err != nil { 291 return err 292 } ··· 315 return pullId - 1, err 316 } 317 318 + func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { 319 + pulls := make(map[int]*Pull) 320 321 rows, err := e.Query(` 322 select ··· 328 target_branch, 329 pull_at, 330 body, 331 + rkey, 332 + source_branch, 333 + source_repo_at 334 from 335 pulls 336 where 337 + repo_at = ? and state = ?`, repoAt, state) 338 if err != nil { 339 return nil, err 340 } ··· 343 for rows.Next() { 344 var pull Pull 345 var createdAt string 346 + var sourceBranch, sourceRepoAt sql.NullString 347 err := rows.Scan( 348 &pull.OwnerDid, 349 &pull.PullId, ··· 354 &pull.PullAt, 355 &pull.Body, 356 &pull.Rkey, 357 + &sourceBranch, 358 + &sourceRepoAt, 359 ) 360 if err != nil { 361 return nil, err ··· 367 } 368 pull.Created = createdTime 369 370 + if sourceBranch.Valid { 371 + pull.PullSource = &PullSource{ 372 + Branch: sourceBranch.String, 373 + } 374 + if sourceRepoAt.Valid { 375 + sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 376 + if err != nil { 377 + return nil, err 378 + } 379 + pull.PullSource.RepoAt = &sourceRepoAtParsed 380 + } 381 + } 382 + 383 + pulls[pull.PullId] = &pull 384 + } 385 + 386 + // get latest round no. for each pull 387 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 388 + submissionsQuery := fmt.Sprintf(` 389 + select 390 + id, pull_id, round_number 391 + from 392 + pull_submissions 393 + where 394 + repo_at = ? and pull_id in (%s) 395 + `, inClause) 396 + 397 + args := make([]any, len(pulls)+1) 398 + args[0] = repoAt.String() 399 + idx := 1 400 + for _, p := range pulls { 401 + args[idx] = p.PullId 402 + idx += 1 403 + } 404 + submissionsRows, err := e.Query(submissionsQuery, args...) 405 + if err != nil { 406 + return nil, err 407 + } 408 + defer submissionsRows.Close() 409 + 410 + for submissionsRows.Next() { 411 + var s PullSubmission 412 + err := submissionsRows.Scan( 413 + &s.ID, 414 + &s.PullId, 415 + &s.RoundNumber, 416 + ) 417 + if err != nil { 418 + return nil, err 419 + } 420 + 421 + if p, ok := pulls[s.PullId]; ok { 422 + p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 423 + p.Submissions[s.RoundNumber] = &s 424 + } 425 + } 426 + if err := rows.Err(); err != nil { 427 + return nil, err 428 } 429 430 + // get comment count on latest submission on each pull 431 + inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 432 + commentsQuery := fmt.Sprintf(` 433 + select 434 + count(id), pull_id 435 + from 436 + pull_comments 437 + where 438 + submission_id in (%s) 439 + group by 440 + submission_id 441 + `, inClause) 442 + 443 + args = []any{} 444 + for _, p := range pulls { 445 + args = append(args, p.Submissions[p.LastRoundNumber()].ID) 446 + } 447 + commentsRows, err := e.Query(commentsQuery, args...) 448 + if err != nil { 449 + return nil, err 450 + } 451 + defer commentsRows.Close() 452 + 453 + for commentsRows.Next() { 454 + var commentCount, pullId int 455 + err := commentsRows.Scan( 456 + &commentCount, 457 + &pullId, 458 + ) 459 + if err != nil { 460 + return nil, err 461 + } 462 + if p, ok := pulls[pullId]; ok { 463 + p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 464 + } 465 + } 466 if err := rows.Err(); err != nil { 467 return nil, err 468 } 469 470 + orderedByDate := []*Pull{} 471 + for _, p := range pulls { 472 + orderedByDate = append(orderedByDate, p) 473 + } 474 + sort.Slice(orderedByDate, func(i, j int) bool { 475 + return orderedByDate[i].Created.After(orderedByDate[j].Created) 476 + }) 477 + 478 + return orderedByDate, nil 479 } 480 481 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 490 pull_at, 491 repo_at, 492 body, 493 + rkey, 494 + source_branch, 495 + source_repo_at 496 from 497 pulls 498 where ··· 502 503 var pull Pull 504 var createdAt string 505 + var sourceBranch, sourceRepoAt sql.NullString 506 err := row.Scan( 507 &pull.OwnerDid, 508 &pull.PullId, ··· 514 &pull.RepoAt, 515 &pull.Body, 516 &pull.Rkey, 517 + &sourceBranch, 518 + &sourceRepoAt, 519 ) 520 if err != nil { 521 return nil, err ··· 527 } 528 pull.Created = createdTime 529 530 + // populate source 531 + if sourceBranch.Valid { 532 + pull.PullSource = &PullSource{ 533 + Branch: sourceBranch.String, 534 + } 535 + if sourceRepoAt.Valid { 536 + sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 537 + if err != nil { 538 + return nil, err 539 + } 540 + pull.PullSource.RepoAt = &sourceRepoAtParsed 541 + } 542 + } 543 + 544 submissionsQuery := ` 545 select 546 + id, pull_id, repo_at, round_number, patch, created, source_rev 547 from 548 pull_submissions 549 where ··· 560 for submissionsRows.Next() { 561 var submission PullSubmission 562 var submissionCreatedStr string 563 + var submissionSourceRev sql.NullString 564 err := submissionsRows.Scan( 565 &submission.ID, 566 &submission.PullId, ··· 568 &submission.RoundNumber, 569 &submission.Patch, 570 &submissionCreatedStr, 571 + &submissionSourceRev, 572 ) 573 if err != nil { 574 return nil, err ··· 579 return nil, err 580 } 581 submission.Created = submissionCreatedTime 582 + 583 + if submissionSourceRev.Valid { 584 + submission.SourceRev = submissionSourceRev.String 585 + } 586 587 submissionsMap[submission.ID] = &submission 588 } ··· 654 return nil, err 655 } 656 657 + var pullSourceRepo *Repo 658 + if pull.PullSource != nil { 659 + if pull.PullSource.RepoAt != nil { 660 + pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 661 + if err != nil { 662 + log.Printf("failed to get repo by at uri: %v", err) 663 + } else { 664 + pull.PullSource.Repo = pullSourceRepo 665 + } 666 + } 667 + } 668 + 669 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 670 for _, submission := range submissionsMap { 671 pull.Submissions[submission.RoundNumber] = submission ··· 674 return &pull, nil 675 } 676 677 + // timeframe here is directly passed into the sql query filter, and any 678 + // timeframe in the past should be negative; e.g.: "-3 months" 679 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { 680 + var pulls []Pull 681 + 682 + rows, err := e.Query(` 683 + select 684 + p.owner_did, 685 + p.repo_at, 686 + p.pull_id, 687 + p.created, 688 + p.title, 689 + p.state, 690 + r.did, 691 + r.name, 692 + r.knot, 693 + r.rkey, 694 + r.created 695 + from 696 + pulls p 697 + join 698 + repos r on p.repo_at = r.at_uri 699 + where 700 + p.owner_did = ? and p.created >= date ('now', ?) 701 + order by 702 + p.created desc`, did, timeframe) 703 + if err != nil { 704 + return nil, err 705 + } 706 + defer rows.Close() 707 + 708 + for rows.Next() { 709 + var pull Pull 710 + var repo Repo 711 + var pullCreatedAt, repoCreatedAt string 712 + err := rows.Scan( 713 + &pull.OwnerDid, 714 + &pull.RepoAt, 715 + &pull.PullId, 716 + &pullCreatedAt, 717 + &pull.Title, 718 + &pull.State, 719 + &repo.Did, 720 + &repo.Name, 721 + &repo.Knot, 722 + &repo.Rkey, 723 + &repoCreatedAt, 724 + ) 725 + if err != nil { 726 + return nil, err 727 + } 728 + 729 + pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt) 730 + if err != nil { 731 + return nil, err 732 + } 733 + pull.Created = pullCreatedTime 734 + 735 + repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 736 + if err != nil { 737 + return nil, err 738 + } 739 + repo.Created = repoCreatedTime 740 + 741 + pull.Repo = &repo 742 + 743 + pulls = append(pulls, pull) 744 + } 745 + 746 + if err := rows.Err(); err != nil { 747 + return nil, err 748 + } 749 + 750 + return pulls, nil 751 + } 752 + 753 func NewPullComment(e Execer, comment *PullComment) (int64, error) { 754 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 755 res, err := e.Exec( ··· 793 return err 794 } 795 796 + func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 797 newRoundNumber := len(pull.Submissions) 798 _, err := e.Exec(` 799 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 800 + values (?, ?, ?, ?, ?) 801 + `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 802 803 return err 804 }
+129 -15
appview/db/repos.go
··· 3 import ( 4 "database/sql" 5 "time" 6 ) 7 8 type Repo struct { ··· 16 17 // optionally, populate this when querying for reverse mappings 18 RepoStats *RepoStats 19 } 20 21 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 22 var repos []Repo 23 24 rows, err := e.Query( 25 - `select did, name, knot, rkey, description, created 26 from repos 27 order by created desc 28 limit ? ··· 37 for rows.Next() { 38 var repo Repo 39 err := scanRepo( 40 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, 41 ) 42 if err != nil { 43 return nil, err ··· 63 r.rkey, 64 r.description, 65 r.created, 66 - count(s.id) as star_count 67 from 68 repos r 69 left join ··· 71 where 72 r.did = ? 73 group by 74 - r.at_uri`, did) 75 if err != nil { 76 return nil, err 77 } ··· 82 var repoStats RepoStats 83 var createdAt string 84 var nullableDescription sql.NullString 85 86 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) 87 if err != nil { 88 return nil, err 89 } 90 91 if nullableDescription.Valid { 92 repo.Description = nullableDescription.String 93 - } else { 94 - repo.Description = "" 95 } 96 97 createdAtTime, err := time.Parse(time.RFC3339, createdAt) ··· 159 160 func AddRepo(e Execer, repo *Repo) error { 161 _, 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, 166 ) 167 return err 168 } 169 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) 172 return err 173 } 174 175 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 176 _, err := e.Exec( 177 `insert into collaborators (did, repo) ··· 249 PullCount PullCount 250 } 251 252 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error { 253 var createdAt string 254 var nullableDescription sql.NullString 255 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil { 256 return err 257 } 258 ··· 267 *created = time.Now() 268 } else { 269 *created = createdAtTime 270 } 271 272 return nil
··· 3 import ( 4 "database/sql" 5 "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 9 10 type Repo struct { ··· 18 19 // optionally, populate this when querying for reverse mappings 20 RepoStats *RepoStats 21 + 22 + // optional 23 + Source string 24 } 25 26 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 27 var repos []Repo 28 29 rows, err := e.Query( 30 + `select did, name, knot, rkey, description, created, source 31 from repos 32 order by created desc 33 limit ? ··· 42 for rows.Next() { 43 var repo Repo 44 err := scanRepo( 45 + rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 46 ) 47 if err != nil { 48 return nil, err ··· 68 r.rkey, 69 r.description, 70 r.created, 71 + count(s.id) as star_count, 72 + r.source 73 from 74 repos r 75 left join ··· 77 where 78 r.did = ? 79 group by 80 + r.at_uri 81 + order by r.created desc`, 82 + did) 83 if err != nil { 84 return nil, err 85 } ··· 90 var repoStats RepoStats 91 var createdAt string 92 var nullableDescription sql.NullString 93 + var nullableSource sql.NullString 94 95 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 96 if err != nil { 97 return nil, err 98 } 99 100 if nullableDescription.Valid { 101 repo.Description = nullableDescription.String 102 + } 103 + 104 + if nullableSource.Valid { 105 + repo.Source = nullableSource.String 106 } 107 108 createdAtTime, err := time.Parse(time.RFC3339, createdAt) ··· 170 171 func AddRepo(e Execer, repo *Repo) error { 172 _, err := e.Exec( 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, 177 ) 178 return err 179 } 180 181 + func RemoveRepo(e Execer, did, name string) error { 182 + _, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name) 183 return err 184 } 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 + 282 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 283 _, err := e.Exec( 284 `insert into collaborators (did, repo) ··· 356 PullCount PullCount 357 } 358 359 + func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 360 var createdAt string 361 var nullableDescription sql.NullString 362 + var nullableSource sql.NullString 363 + if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 364 return err 365 } 366 ··· 375 *created = time.Now() 376 } else { 377 *created = createdAtTime 378 + } 379 + 380 + if nullableSource.Valid { 381 + *source = nullableSource.String 382 + } else { 383 + *source = "" 384 } 385 386 return nil
+13
appview/db/timeline.go
··· 9 *Repo 10 *Follow 11 *Star 12 EventAt time.Time 13 } 14 15 // TODO: this gathers heterogenous events from different sources and aggregates ··· 34 } 35 36 for _, repo := range repos { 37 events = append(events, TimelineEvent{ 38 Repo: &repo, 39 EventAt: repo.Created, 40 }) 41 } 42
··· 9 *Repo 10 *Follow 11 *Star 12 + 13 EventAt time.Time 14 + 15 + // optional: populate only if Repo is a fork 16 + Source *Repo 17 } 18 19 // TODO: this gathers heterogenous events from different sources and aggregates ··· 38 } 39 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 + 49 events = append(events, TimelineEvent{ 50 Repo: &repo, 51 EventAt: repo.Created, 52 + Source: sourceRepo, 53 }) 54 } 55
+62
appview/filetree/filetree.go
···
··· 1 + package filetree 2 + 3 + import ( 4 + "path/filepath" 5 + "sort" 6 + "strings" 7 + ) 8 + 9 + type FileTreeNode struct { 10 + Name string 11 + Path string 12 + IsDirectory bool 13 + Children map[string]*FileTreeNode 14 + } 15 + 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 18 + return &FileTreeNode{ 19 + Name: name, 20 + Path: path, 21 + IsDirectory: isDir, 22 + Children: make(map[string]*FileTreeNode), 23 + } 24 + } 25 + 26 + func FileTree(files []string) *FileTreeNode { 27 + rootNode := newNode("", "", true) 28 + 29 + sort.Strings(files) 30 + 31 + for _, file := range files { 32 + if file == "" { 33 + continue 34 + } 35 + 36 + parts := strings.Split(filepath.Clean(file), "/") 37 + if len(parts) == 0 { 38 + continue 39 + } 40 + 41 + currentNode := rootNode 42 + currentPath := "" 43 + 44 + for i, part := range parts { 45 + if currentPath == "" { 46 + currentPath = part 47 + } else { 48 + currentPath = filepath.Join(currentPath, part) 49 + } 50 + 51 + isDir := i < len(parts)-1 52 + 53 + if _, exists := currentNode.Children[part]; !exists { 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 55 + } 56 + 57 + currentNode = currentNode.Children[part] 58 + } 59 + } 60 + 61 + return rootNode 62 + }
+126
appview/middleware/middleware.go
···
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "tangled.sh/tangled.sh/core/appview" 13 + "tangled.sh/tangled.sh/core/appview/auth" 14 + "tangled.sh/tangled.sh/core/appview/pagination" 15 + ) 16 + 17 + type Middleware func(http.Handler) http.Handler 18 + 19 + func AuthMiddleware(a *auth.Auth) Middleware { 20 + return func(next http.Handler) http.Handler { 21 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 + redirectFunc := func(w http.ResponseWriter, r *http.Request) { 23 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 24 + } 25 + if r.Header.Get("HX-Request") == "true" { 26 + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 27 + w.Header().Set("HX-Redirect", "/login") 28 + w.WriteHeader(http.StatusOK) 29 + } 30 + } 31 + 32 + session, err := a.GetSession(r) 33 + if session.IsNew || err != nil { 34 + log.Printf("not logged in, redirecting") 35 + redirectFunc(w, r) 36 + return 37 + } 38 + 39 + authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 40 + if !ok || !authorized { 41 + log.Printf("not logged in, redirecting") 42 + redirectFunc(w, r) 43 + return 44 + } 45 + 46 + // refresh if nearing expiry 47 + // TODO: dedup with /login 48 + expiryStr := session.Values[appview.SessionExpiry].(string) 49 + expiry, err := time.Parse(time.RFC3339, expiryStr) 50 + if err != nil { 51 + log.Println("invalid expiry time", err) 52 + redirectFunc(w, r) 53 + return 54 + } 55 + pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 56 + did, ok2 := session.Values[appview.SessionDid].(string) 57 + refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 58 + 59 + if !ok1 || !ok2 || !ok3 { 60 + log.Println("invalid expiry time", err) 61 + redirectFunc(w, r) 62 + return 63 + } 64 + 65 + if time.Now().After(expiry) { 66 + log.Println("token expired, refreshing ...") 67 + 68 + client := xrpc.Client{ 69 + Host: pdsUrl, 70 + Auth: &xrpc.AuthInfo{ 71 + Did: did, 72 + AccessJwt: refreshJwt, 73 + RefreshJwt: refreshJwt, 74 + }, 75 + } 76 + atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 77 + if err != nil { 78 + log.Println("failed to refresh session", err) 79 + redirectFunc(w, r) 80 + return 81 + } 82 + 83 + sessionish := auth.RefreshSessionWrapper{atSession} 84 + 85 + err = a.StoreSession(r, w, &sessionish, pdsUrl) 86 + if err != nil { 87 + log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 88 + return 89 + } 90 + 91 + log.Println("successfully refreshed token") 92 + } 93 + 94 + next.ServeHTTP(w, r) 95 + }) 96 + } 97 + } 98 + 99 + func Paginate(next http.Handler) http.Handler { 100 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 101 + page := pagination.FirstPage() 102 + 103 + offsetVal := r.URL.Query().Get("offset") 104 + if offsetVal != "" { 105 + offset, err := strconv.Atoi(offsetVal) 106 + if err != nil { 107 + log.Println("invalid offset") 108 + } else { 109 + page.Offset = offset 110 + } 111 + } 112 + 113 + limitVal := r.URL.Query().Get("limit") 114 + if limitVal != "" { 115 + limit, err := strconv.Atoi(limitVal) 116 + if err != nil { 117 + log.Println("invalid limit") 118 + } else { 119 + page.Limit = limit 120 + } 121 + } 122 + 123 + ctx := context.WithValue(r.Context(), "page", page) 124 + next.ServeHTTP(w, r.WithContext(ctx)) 125 + }) 126 + }
+12 -1
appview/pages/funcmap.go
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 ) 17 18 func funcMap() template.FuncMap { ··· 30 return strings.Split(s, sep) 31 }, 32 "add": func(a, b int) int { 33 return a + b 34 }, 35 "sub": func(a, b int) int { ··· 68 return s 69 }, 70 "timeFmt": humanize.Time, 71 "shortTimeFmt": func(t time.Time) string { 72 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 73 {time.Second, "now", time.Second}, ··· 134 return v.Slice(start, end).Interface() 135 }, 136 "markdown": func(text string) template.HTML { 137 - return template.HTML(renderMarkdown(text)) 138 }, 139 "isNil": func(t any) bool { 140 // returns false for other "zero" values ··· 165 } 166 return template.HTML(data) 167 }, 168 } 169 } 170
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 + "tangled.sh/tangled.sh/core/appview/filetree" 17 + "tangled.sh/tangled.sh/core/appview/pages/markup" 18 ) 19 20 func funcMap() template.FuncMap { ··· 32 return strings.Split(s, sep) 33 }, 34 "add": func(a, b int) int { 35 + return a + b 36 + }, 37 + // the absolute state of go templates 38 + "add64": func(a, b int64) int64 { 39 return a + b 40 }, 41 "sub": func(a, b int) int { ··· 74 return s 75 }, 76 "timeFmt": humanize.Time, 77 + "longTimeFmt": func(t time.Time) string { 78 + return t.Format("2006-01-02 * 3:04 PM") 79 + }, 80 "shortTimeFmt": func(t time.Time) string { 81 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 82 {time.Second, "now", time.Second}, ··· 143 return v.Slice(start, end).Interface() 144 }, 145 "markdown": func(text string) template.HTML { 146 + return template.HTML(markup.RenderMarkdown(text)) 147 }, 148 "isNil": func(t any) bool { 149 // returns false for other "zero" values ··· 174 } 175 return template.HTML(data) 176 }, 177 + "cssContentHash": CssContentHash, 178 + "fileTree": filetree.FileTree, 179 } 180 } 181
-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
···
··· 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
···
··· 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 + }
+383 -102
appview/pages/pages.go
··· 2 3 import ( 4 "bytes" 5 "embed" 6 "fmt" 7 "html/template" 8 "io" 9 "io/fs" 10 "log" 11 "net/http" 12 "path" 13 "path/filepath" 14 "slices" 15 "strings" 16 17 "github.com/alecthomas/chroma/v2" 18 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 19 "github.com/alecthomas/chroma/v2/lexers" 20 "github.com/alecthomas/chroma/v2/styles" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 "github.com/microcosm-cc/bluemonday" 23 - "tangled.sh/tangled.sh/core/appview/auth" 24 - "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/state/userutil" 26 - "tangled.sh/tangled.sh/core/types" 27 ) 28 29 //go:embed templates/* static 30 var Files embed.FS 31 32 type Pages struct { 33 - t map[string]*template.Template 34 } 35 36 - func NewPages() *Pages { 37 templates := make(map[string]*template.Template) 38 39 - // Walk through embedded templates directory and parse all .html files 40 - err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 41 if err != nil { 42 return err 43 } 44 45 - if !d.IsDir() && strings.HasSuffix(path, ".html") { 46 - name := strings.TrimPrefix(path, "templates/") 47 - name = strings.TrimSuffix(name, ".html") 48 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 - } 57 58 - templates[name] = tmpl 59 - log.Printf("loaded fragment: %s", name) 60 - } 61 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 - } 72 73 - templates[name] = tmpl 74 - log.Printf("loaded template: %s", name) 75 - } 76 - 77 return nil 78 } 79 return nil 80 }) 81 if err != nil { 82 - log.Fatalf("walking template dir: %v", err) 83 } 84 85 - log.Printf("total templates loaded: %d", len(templates)) 86 87 - return &Pages{ 88 - t: templates, 89 } 90 } 91 92 - type LoginParams struct { 93 } 94 95 func (p *Pages) execute(name string, w io.Writer, params any) error { 96 - return p.t[name].ExecuteTemplate(w, "layouts/base", params) 97 } 98 99 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 100 - return p.t[name].Execute(w, params) 101 } 102 103 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 104 - return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 105 } 106 107 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 139 140 type KnotParams struct { 141 LoggedInUser *auth.User 142 Registration *db.Registration 143 Members []string 144 IsOwner bool ··· 157 return p.execute("repo/new", w, params) 158 } 159 160 type ProfilePageParams struct { 161 LoggedInUser *auth.User 162 UserDid string ··· 165 CollaboratingRepos []db.Repo 166 ProfileStats ProfileStats 167 FollowStatus db.FollowStatus 168 - DidHandleMap map[string]string 169 AvatarUri string 170 } 171 172 type ProfileStats struct { ··· 184 } 185 186 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 187 - return p.executePlain("fragments/follow", w, params) 188 } 189 190 - type StarFragmentParams struct { 191 IsStarred bool 192 RepoAt syntax.ATURI 193 Stats db.RepoStats 194 } 195 196 - func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error { 197 - return p.executePlain("fragments/star", w, params) 198 } 199 200 type RepoDescriptionParams struct { ··· 202 } 203 204 func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 205 - return p.executePlain("fragments/editRepoDescription", w, params) 206 } 207 208 func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 209 - return p.executePlain("fragments/repoDescription", w, params) 210 } 211 212 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 222 } 223 224 type RolesInRepo struct { ··· 229 return slices.Contains(r.Roles, "repo:settings") 230 } 231 232 func (r RolesInRepo) IsOwner() bool { 233 return slices.Contains(r.Roles, "repo:owner") 234 } ··· 267 268 func (r RepoInfo) GetTabs() [][]string { 269 tabs := [][]string{ 270 - {"overview", "/"}, 271 - {"issues", "/issues"}, 272 - {"pulls", "/pulls"}, 273 } 274 275 if r.Roles.SettingsAllowed() { 276 - tabs = append(tabs, []string{"settings", "/settings"}) 277 } 278 279 return tabs ··· 324 ext := filepath.Ext(params.ReadmeFileName) 325 switch ext { 326 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 327 - htmlString = renderMarkdown(params.Readme) 328 params.Raw = false 329 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 330 default: ··· 351 } 352 353 type RepoCommitParams struct { 354 - LoggedInUser *auth.User 355 - RepoInfo RepoInfo 356 - Active string 357 - types.RepoCommitResponse 358 EmailToDidOrHandle map[string]string 359 } 360 361 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 420 } 421 422 type RepoBlobParams struct { 423 - LoggedInUser *auth.User 424 - RepoInfo RepoInfo 425 - Active string 426 - BreadCrumbs [][]string 427 types.RepoBlobResponse 428 } 429 430 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 431 - style := styles.Get("bw") 432 - b := style.Builder() 433 - b.Add(chroma.LiteralString, "noitalic") 434 - style, _ = b.Build() 435 436 if params.Lines < 5000 { 437 c := params.Contents 438 formatter := chromahtml.New( 439 - chromahtml.InlineCode(true), 440 chromahtml.WithLineNumbers(true), 441 chromahtml.WithLinkableLineNumbers(true, "L"), 442 chromahtml.Standalone(false), 443 ) 444 445 lexer := lexers.Get(filepath.Base(params.Path)) ··· 476 RepoInfo RepoInfo 477 Collaborators []Collaborator 478 Active string 479 // TODO: use repoinfo.roles 480 IsCollaboratorInviteAllowed bool 481 } ··· 486 } 487 488 type RepoIssuesParams struct { 489 - LoggedInUser *auth.User 490 - RepoInfo RepoInfo 491 - Active string 492 - Issues []db.Issue 493 - DidHandleMap map[string]string 494 - 495 FilteringByOpen bool 496 } 497 ··· 533 return p.executeRepo("repo/issues/new", w, params) 534 } 535 536 type RepoNewPullParams struct { 537 LoggedInUser *auth.User 538 RepoInfo RepoInfo ··· 548 type RepoPullsParams struct { 549 LoggedInUser *auth.User 550 RepoInfo RepoInfo 551 - Pulls []db.Pull 552 Active string 553 DidHandleMap map[string]string 554 FilteringBy db.PullState ··· 559 return p.executeRepo("repo/pulls/pulls", w, params) 560 } 561 562 - type RepoSinglePullParams struct { 563 - LoggedInUser *auth.User 564 - RepoInfo RepoInfo 565 - Active string 566 - DidHandleMap map[string]string 567 568 - Pull db.Pull 569 - MergeCheck types.MergeCheckResponse 570 } 571 572 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 579 DidHandleMap map[string]string 580 RepoInfo RepoInfo 581 Pull *db.Pull 582 - Diff types.NiceDiff 583 Round int 584 Submission *db.PullSubmission 585 } ··· 589 return p.execute("repo/pulls/patch", w, params) 590 } 591 592 type PullResubmitParams struct { 593 LoggedInUser *auth.User 594 RepoInfo RepoInfo ··· 597 } 598 599 func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 600 - return p.executePlain("fragments/pullResubmit", w, params) 601 } 602 603 type PullActionsParams struct { 604 - LoggedInUser *auth.User 605 - RepoInfo RepoInfo 606 - Pull *db.Pull 607 - RoundNumber int 608 - MergeCheck types.MergeCheckResponse 609 } 610 611 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 612 - return p.executePlain("fragments/pullActions", w, params) 613 } 614 615 type PullNewCommentParams struct { ··· 620 } 621 622 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 623 - return p.executePlain("fragments/pullNewComment", w, params) 624 } 625 626 func (p *Pages) Static() http.Handler { 627 sub, err := fs.Sub(Files, "static") 628 if err != nil { 629 log.Fatalf("no static dir found? that's crazy: %v", err) ··· 634 635 func Cache(h http.Handler) http.Handler { 636 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 637 - if strings.HasSuffix(r.URL.Path, ".css") { 638 // on day for css files 639 w.Header().Set("Cache-Control", "public, max-age=86400") 640 } else { ··· 642 } 643 h.ServeHTTP(w, r) 644 }) 645 } 646 647 func (p *Pages) Error500(w io.Writer) error {
··· 2 3 import ( 4 "bytes" 5 + "crypto/sha256" 6 "embed" 7 + "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log" 13 "net/http" 14 + "os" 15 "path" 16 "path/filepath" 17 "slices" 18 "strings" 19 20 + "tangled.sh/tangled.sh/core/appview/auth" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 23 + "tangled.sh/tangled.sh/core/appview/pagination" 24 + "tangled.sh/tangled.sh/core/appview/state/userutil" 25 + "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/types" 27 + 28 "github.com/alecthomas/chroma/v2" 29 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 30 "github.com/alecthomas/chroma/v2/lexers" 31 "github.com/alecthomas/chroma/v2/styles" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 "github.com/microcosm-cc/bluemonday" 34 ) 35 36 //go:embed templates/* static 37 var Files embed.FS 38 39 type Pages struct { 40 + t map[string]*template.Template 41 + dev bool 42 + embedFS embed.FS 43 + templateDir string // Path to templates on disk for dev mode 44 + } 45 + 46 + func NewPages(dev bool) *Pages { 47 + p := &Pages{ 48 + t: make(map[string]*template.Template), 49 + dev: dev, 50 + embedFS: Files, 51 + templateDir: "appview/pages", 52 + } 53 + 54 + // Initial load of all templates 55 + p.loadAllTemplates() 56 + 57 + return p 58 } 59 60 + func (p *Pages) loadAllTemplates() { 61 templates := make(map[string]*template.Template) 62 + var fragmentPaths []string 63 64 + // Use embedded FS for initial loading 65 + // First, collect all fragment paths 66 + err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 67 if err != nil { 68 return err 69 } 70 + if d.IsDir() { 71 + return nil 72 + } 73 + if !strings.HasSuffix(path, ".html") { 74 + return nil 75 + } 76 + if !strings.Contains(path, "fragments/") { 77 + return nil 78 + } 79 + name := strings.TrimPrefix(path, "templates/") 80 + name = strings.TrimSuffix(name, ".html") 81 + tmpl, err := template.New(name). 82 + Funcs(funcMap()). 83 + ParseFS(p.embedFS, path) 84 + if err != nil { 85 + log.Fatalf("setting up fragment: %v", err) 86 + } 87 + templates[name] = tmpl 88 + fragmentPaths = append(fragmentPaths, path) 89 + log.Printf("loaded fragment: %s", name) 90 + return nil 91 + }) 92 + if err != nil { 93 + log.Fatalf("walking template dir for fragments: %v", err) 94 + } 95 96 + // Then walk through and setup the rest of the templates 97 + err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 98 + if err != nil { 99 + return err 100 + } 101 + if d.IsDir() { 102 + return nil 103 + } 104 + if !strings.HasSuffix(path, "html") { 105 + return nil 106 + } 107 + // Skip fragments as they've already been loaded 108 + if strings.Contains(path, "fragments/") { 109 + return nil 110 + } 111 + // Skip layouts 112 + if strings.Contains(path, "layouts/") { 113 + return nil 114 + } 115 + name := strings.TrimPrefix(path, "templates/") 116 + name = strings.TrimSuffix(name, ".html") 117 + // Add the page template on top of the base 118 + allPaths := []string{} 119 + allPaths = append(allPaths, "templates/layouts/*.html") 120 + allPaths = append(allPaths, fragmentPaths...) 121 + allPaths = append(allPaths, path) 122 + tmpl, err := template.New(name). 123 + Funcs(funcMap()). 124 + ParseFS(p.embedFS, allPaths...) 125 + if err != nil { 126 + return fmt.Errorf("setting up template: %w", err) 127 + } 128 + templates[name] = tmpl 129 + log.Printf("loaded template: %s", name) 130 + return nil 131 + }) 132 + if err != nil { 133 + log.Fatalf("walking template dir: %v", err) 134 + } 135 136 + log.Printf("total templates loaded: %d", len(templates)) 137 + p.t = templates 138 + } 139 140 + // loadTemplateFromDisk loads a template from the filesystem in dev mode 141 + func (p *Pages) loadTemplateFromDisk(name string) error { 142 + if !p.dev { 143 + return nil 144 + } 145 146 + log.Printf("reloading template from disk: %s", name) 147 148 + // Find all fragments first 149 + var fragmentPaths []string 150 + err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 151 + if err != nil { 152 + return err 153 + } 154 + if d.IsDir() { 155 + return nil 156 + } 157 + if !strings.HasSuffix(path, ".html") { 158 + return nil 159 + } 160 + if !strings.Contains(path, "fragments/") { 161 return nil 162 } 163 + fragmentPaths = append(fragmentPaths, path) 164 return nil 165 }) 166 if err != nil { 167 + return fmt.Errorf("walking disk template dir for fragments: %w", err) 168 + } 169 + 170 + // Find the template path on disk 171 + templatePath := filepath.Join(p.templateDir, "templates", name+".html") 172 + if _, err := os.Stat(templatePath); os.IsNotExist(err) { 173 + return fmt.Errorf("template not found on disk: %s", name) 174 } 175 176 + // Create a new template 177 + tmpl := template.New(name).Funcs(funcMap()) 178 179 + // Parse layouts 180 + layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 181 + layouts, err := filepath.Glob(layoutGlob) 182 + if err != nil { 183 + return fmt.Errorf("finding layout templates: %w", err) 184 + } 185 + 186 + // Create paths for parsing 187 + allFiles := append(layouts, fragmentPaths...) 188 + allFiles = append(allFiles, templatePath) 189 + 190 + // Parse all templates 191 + tmpl, err = tmpl.ParseFiles(allFiles...) 192 + if err != nil { 193 + return fmt.Errorf("parsing template files: %w", err) 194 } 195 + 196 + // Update the template in the map 197 + p.t[name] = tmpl 198 + log.Printf("template reloaded from disk: %s", name) 199 + return nil 200 } 201 202 + func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 203 + // In dev mode, reload the template from disk before executing 204 + if p.dev { 205 + if err := p.loadTemplateFromDisk(templateName); err != nil { 206 + log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 207 + // Continue with the existing template 208 + } 209 + } 210 + 211 + tmpl, exists := p.t[templateName] 212 + if !exists { 213 + return fmt.Errorf("template not found: %s", templateName) 214 + } 215 + 216 + if base == "" { 217 + return tmpl.Execute(w, params) 218 + } else { 219 + return tmpl.ExecuteTemplate(w, base, params) 220 + } 221 } 222 223 func (p *Pages) execute(name string, w io.Writer, params any) error { 224 + return p.executeOrReload(name, w, "layouts/base", params) 225 } 226 227 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 228 + return p.executeOrReload(name, w, "", params) 229 } 230 231 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 232 + return p.executeOrReload(name, w, "layouts/repobase", params) 233 + } 234 + 235 + type LoginParams struct { 236 } 237 238 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 270 271 type KnotParams struct { 272 LoggedInUser *auth.User 273 + DidHandleMap map[string]string 274 Registration *db.Registration 275 Members []string 276 IsOwner bool ··· 289 return p.execute("repo/new", w, params) 290 } 291 292 + type ForkRepoParams struct { 293 + LoggedInUser *auth.User 294 + Knots []string 295 + RepoInfo RepoInfo 296 + } 297 + 298 + func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 299 + return p.execute("repo/fork", w, params) 300 + } 301 + 302 type ProfilePageParams struct { 303 LoggedInUser *auth.User 304 UserDid string ··· 307 CollaboratingRepos []db.Repo 308 ProfileStats ProfileStats 309 FollowStatus db.FollowStatus 310 AvatarUri string 311 + ProfileTimeline *db.ProfileTimeline 312 + 313 + DidHandleMap map[string]string 314 } 315 316 type ProfileStats struct { ··· 328 } 329 330 func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 331 + return p.executePlain("user/fragments/follow", w, params) 332 } 333 334 + type RepoActionsFragmentParams struct { 335 IsStarred bool 336 RepoAt syntax.ATURI 337 Stats db.RepoStats 338 } 339 340 + func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 341 + return p.executePlain("repo/fragments/repoActions", w, params) 342 } 343 344 type RepoDescriptionParams struct { ··· 346 } 347 348 func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 349 + return p.executePlain("repo/fragments/editRepoDescription", w, params) 350 } 351 352 func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 353 + return p.executePlain("repo/fragments/repoDescription", w, params) 354 } 355 356 type RepoInfo struct { 357 + Name string 358 + OwnerDid string 359 + OwnerHandle string 360 + Description string 361 + Knot string 362 + RepoAt syntax.ATURI 363 + IsStarred bool 364 + Stats db.RepoStats 365 + Roles RolesInRepo 366 + Source *db.Repo 367 + SourceHandle string 368 + DisableFork bool 369 } 370 371 type RolesInRepo struct { ··· 376 return slices.Contains(r.Roles, "repo:settings") 377 } 378 379 + func (r RolesInRepo) CollaboratorInviteAllowed() bool { 380 + return slices.Contains(r.Roles, "repo:invite") 381 + } 382 + 383 + func (r RolesInRepo) RepoDeleteAllowed() bool { 384 + return slices.Contains(r.Roles, "repo:delete") 385 + } 386 + 387 func (r RolesInRepo) IsOwner() bool { 388 return slices.Contains(r.Roles, "repo:owner") 389 } ··· 422 423 func (r RepoInfo) GetTabs() [][]string { 424 tabs := [][]string{ 425 + {"overview", "/", "square-chart-gantt"}, 426 + {"issues", "/issues", "circle-dot"}, 427 + {"pulls", "/pulls", "git-pull-request"}, 428 } 429 430 if r.Roles.SettingsAllowed() { 431 + tabs = append(tabs, []string{"settings", "/settings", "cog"}) 432 } 433 434 return tabs ··· 479 ext := filepath.Ext(params.ReadmeFileName) 480 switch ext { 481 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 482 + htmlString = markup.RenderMarkdown(params.Readme) 483 params.Raw = false 484 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 485 default: ··· 506 } 507 508 type RepoCommitParams struct { 509 + LoggedInUser *auth.User 510 + RepoInfo RepoInfo 511 + Active string 512 EmailToDidOrHandle map[string]string 513 + 514 + types.RepoCommitResponse 515 } 516 517 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 576 } 577 578 type RepoBlobParams struct { 579 + LoggedInUser *auth.User 580 + RepoInfo RepoInfo 581 + Active string 582 + BreadCrumbs [][]string 583 + ShowRendered bool 584 + RenderToggle bool 585 + RenderedContents template.HTML 586 types.RepoBlobResponse 587 } 588 589 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 590 + var style *chroma.Style = styles.Get("catpuccin-latte") 591 + 592 + if params.ShowRendered { 593 + switch markup.GetFormat(params.Path) { 594 + case markup.FormatMarkdown: 595 + params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 596 + } 597 + } 598 599 if params.Lines < 5000 { 600 c := params.Contents 601 formatter := chromahtml.New( 602 + chromahtml.InlineCode(false), 603 chromahtml.WithLineNumbers(true), 604 chromahtml.WithLinkableLineNumbers(true, "L"), 605 chromahtml.Standalone(false), 606 + chromahtml.WithClasses(true), 607 ) 608 609 lexer := lexers.Get(filepath.Base(params.Path)) ··· 640 RepoInfo RepoInfo 641 Collaborators []Collaborator 642 Active string 643 + Branches []string 644 + DefaultBranch string 645 // TODO: use repoinfo.roles 646 IsCollaboratorInviteAllowed bool 647 } ··· 652 } 653 654 type RepoIssuesParams struct { 655 + LoggedInUser *auth.User 656 + RepoInfo RepoInfo 657 + Active string 658 + Issues []db.Issue 659 + DidHandleMap map[string]string 660 + Page pagination.Page 661 FilteringByOpen bool 662 } 663 ··· 699 return p.executeRepo("repo/issues/new", w, params) 700 } 701 702 + type EditIssueCommentParams struct { 703 + LoggedInUser *auth.User 704 + RepoInfo RepoInfo 705 + Issue *db.Issue 706 + Comment *db.Comment 707 + } 708 + 709 + func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 710 + return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 711 + } 712 + 713 + type SingleIssueCommentParams struct { 714 + LoggedInUser *auth.User 715 + DidHandleMap map[string]string 716 + RepoInfo RepoInfo 717 + Issue *db.Issue 718 + Comment *db.Comment 719 + } 720 + 721 + func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 722 + return p.executePlain("repo/issues/fragments/issueComment", w, params) 723 + } 724 + 725 type RepoNewPullParams struct { 726 LoggedInUser *auth.User 727 RepoInfo RepoInfo ··· 737 type RepoPullsParams struct { 738 LoggedInUser *auth.User 739 RepoInfo RepoInfo 740 + Pulls []*db.Pull 741 Active string 742 DidHandleMap map[string]string 743 FilteringBy db.PullState ··· 748 return p.executeRepo("repo/pulls/pulls", w, params) 749 } 750 751 + type ResubmitResult uint64 752 753 + const ( 754 + ShouldResubmit ResubmitResult = iota 755 + ShouldNotResubmit 756 + Unknown 757 + ) 758 + 759 + func (r ResubmitResult) Yes() bool { 760 + return r == ShouldResubmit 761 + } 762 + func (r ResubmitResult) No() bool { 763 + return r == ShouldNotResubmit 764 + } 765 + func (r ResubmitResult) Unknown() bool { 766 + return r == Unknown 767 + } 768 + 769 + type RepoSinglePullParams struct { 770 + LoggedInUser *auth.User 771 + RepoInfo RepoInfo 772 + Active string 773 + DidHandleMap map[string]string 774 + Pull *db.Pull 775 + MergeCheck types.MergeCheckResponse 776 + ResubmitCheck ResubmitResult 777 } 778 779 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 786 DidHandleMap map[string]string 787 RepoInfo RepoInfo 788 Pull *db.Pull 789 + Diff *types.NiceDiff 790 Round int 791 Submission *db.PullSubmission 792 } ··· 796 return p.execute("repo/pulls/patch", w, params) 797 } 798 799 + type RepoPullInterdiffParams struct { 800 + LoggedInUser *auth.User 801 + DidHandleMap map[string]string 802 + RepoInfo RepoInfo 803 + Pull *db.Pull 804 + Round int 805 + Interdiff *patchutil.InterdiffResult 806 + } 807 + 808 + // this name is a mouthful 809 + func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 810 + return p.execute("repo/pulls/interdiff", w, params) 811 + } 812 + 813 + type PullPatchUploadParams struct { 814 + RepoInfo RepoInfo 815 + } 816 + 817 + func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 818 + return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 819 + } 820 + 821 + type PullCompareBranchesParams struct { 822 + RepoInfo RepoInfo 823 + Branches []types.Branch 824 + } 825 + 826 + func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 827 + return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 828 + } 829 + 830 + type PullCompareForkParams struct { 831 + RepoInfo RepoInfo 832 + Forks []db.Repo 833 + } 834 + 835 + func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 836 + return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 837 + } 838 + 839 + type PullCompareForkBranchesParams struct { 840 + RepoInfo RepoInfo 841 + SourceBranches []types.Branch 842 + TargetBranches []types.Branch 843 + } 844 + 845 + func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 846 + return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 847 + } 848 + 849 type PullResubmitParams struct { 850 LoggedInUser *auth.User 851 RepoInfo RepoInfo ··· 854 } 855 856 func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 857 + return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 858 } 859 860 type PullActionsParams struct { 861 + LoggedInUser *auth.User 862 + RepoInfo RepoInfo 863 + Pull *db.Pull 864 + RoundNumber int 865 + MergeCheck types.MergeCheckResponse 866 + ResubmitCheck ResubmitResult 867 } 868 869 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 870 + return p.executePlain("repo/pulls/fragments/pullActions", w, params) 871 } 872 873 type PullNewCommentParams struct { ··· 878 } 879 880 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 881 + return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 882 } 883 884 func (p *Pages) Static() http.Handler { 885 + if p.dev { 886 + return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 887 + } 888 + 889 sub, err := fs.Sub(Files, "static") 890 if err != nil { 891 log.Fatalf("no static dir found? that's crazy: %v", err) ··· 896 897 func Cache(h http.Handler) http.Handler { 898 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 899 + path := strings.Split(r.URL.Path, "?")[0] 900 + 901 + if strings.HasSuffix(path, ".css") { 902 // on day for css files 903 w.Header().Set("Cache-Control", "public, max-age=86400") 904 } else { ··· 906 } 907 h.ServeHTTP(w, r) 908 }) 909 + } 910 + 911 + func CssContentHash() string { 912 + cssFile, err := Files.Open("static/tw.css") 913 + if err != nil { 914 + log.Printf("Error opening CSS file: %v", err) 915 + return "" 916 + } 917 + defer cssFile.Close() 918 + 919 + hasher := sha256.New() 920 + if _, err := io.Copy(hasher, cssFile); err != nil { 921 + log.Printf("Error hashing CSS file: %v", err) 922 + return "" 923 + } 924 + 925 + return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 926 } 927 928 func (p *Pages) Error500(w io.Writer) error {
-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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 - {{define "title"}}{{ .Registration.Domain }}{{end}} 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> 18 {{ end }} 19 - </p> 20 - 21 {{ if .Registration.Registered }} 22 - <h3> members </h3> 23 - <ol> 24 - {{ range $.Members }} 25 - <li><a href="/{{.}}">{{.}}</a></li> 26 {{ else }} 27 - <p>no members</p> 28 {{ end }} 29 - {{ end }} 30 - </ol> 31 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}}
··· 1 + {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 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> 25 {{ end }} 26 + </dd> 27 + 28 + <dt class="font-bold">opened</dt> 29 + <dd>{{ .Registration.Created | timeFmt }}</dd> 30 + 31 {{ if .Registration.Registered }} 32 + <dt class="font-bold">registered</dt> 33 + <dd>{{ .Registration.Registered | timeFmt }}</dd> 34 {{ else }} 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> 39 {{ end }} 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 }} 76 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
··· 1 {{ define "title" }}knots{{ end }} 2 - 3 {{ define "content" }} 4 - <h1>knots</h1> 5 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 - /> 20 </div> 21 - <button class="btn" type="submit">generate key</button> 22 - </form> 23 </section> 24 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> 51 </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> 92 {{ end }}
··· 1 {{ define "title" }}knots{{ end }} 2 {{ define "content" }} 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> 28 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> 44 </div> 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> 51 </section> 52 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> 85 </section> 86 + </div> 87 {{ end }}
+5 -6
appview/pages/templates/layouts/base.html
··· 1 {{ define "layouts/base" }} 2 <!doctype html> 3 - <html lang="en"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 - <link href="/static/tw.css" rel="stylesheet" type="text/css" /> 12 - 13 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 14 {{ block "extrameta" . }}{{ end }} 15 </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"> 19 {{ block "topbar" . }} 20 {{ template "layouts/topbar" . }} 21 {{ end }}
··· 1 {{ define "layouts/base" }} 2 <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 13 {{ block "extrameta" . }}{{ end }} 14 </head> 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"> 18 {{ block "topbar" . }} 19 {{ template "layouts/topbar" . }} 20 {{ end }}
+2 -2
appview/pages/templates/layouts/footer.html
··· 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white rounded-t"> 3 - <div class="container mx-auto text-center text-gray-600 text-sm"> 4 <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppili.bsky.social">@oppili.bsky.social</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 </div> 6 </div>
··· 1 {{ define "layouts/footer" }} 2 + <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t"> 3 + <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppili.bsky.social">@oppili.bsky.social</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 </div> 6 </div>
+34 -19
appview/pages/templates/layouts/repobase.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 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> 15 <section class="min-h-screen flex flex-col drop-shadow-sm"> 16 <nav class="w-full pl-4 overflow-auto"> 17 <div class="flex z-60"> 18 - {{ $activeTabStyles := "-mb-px bg-white" }} 19 {{ $tabs := .RepoInfo.GetTabs }} 20 {{ $tabmeta := .RepoInfo.TabMetadata }} 21 {{ range $item := $tabs }} 22 {{ $key := index $item 0 }} 23 {{ $value := index $item 1 }} 24 {{ $meta := index $tabmeta $key }} 25 <a 26 href="/{{ $.RepoInfo.FullName }}{{ $value }}" ··· 28 hx-boost="true" 29 > 30 <div 31 - class="px-4 py-1 mr-1 text-black min-w-[80px] text-center relative rounded-t whitespace-nowrap 32 {{ if eq $.Active $key }} 33 {{ $activeTabStyles }} 34 {{ else }} 35 - group-hover:bg-gray-200 36 {{ end }} 37 " 38 > 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 }} 43 </div> 44 </a> 45 {{ end }} 46 </div> 47 </nav> 48 <section 49 - class="bg-white p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm" 50 > 51 {{ block "repoContent" . }}{{ end }} 52 </section>
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 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> 26 <section class="min-h-screen flex flex-col drop-shadow-sm"> 27 <nav class="w-full pl-4 overflow-auto"> 28 <div class="flex z-60"> 29 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 30 {{ $tabs := .RepoInfo.GetTabs }} 31 {{ $tabmeta := .RepoInfo.TabMetadata }} 32 {{ range $item := $tabs }} 33 {{ $key := index $item 0 }} 34 {{ $value := index $item 1 }} 35 + {{ $icon := index $item 2 }} 36 {{ $meta := index $tabmeta $key }} 37 <a 38 href="/{{ $.RepoInfo.FullName }}{{ $value }}" ··· 40 hx-boost="true" 41 > 42 <div 43 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 44 {{ if eq $.Active $key }} 45 {{ $activeTabStyles }} 46 {{ else }} 47 + group-hover:bg-gray-200 dark:group-hover:bg-gray-700 48 {{ end }} 49 " 50 > 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> 58 </div> 59 </a> 60 {{ end }} 61 </div> 62 </nav> 63 <section 64 + class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white" 65 > 66 {{ block "repoContent" . }}{{ end }} 67 </section>
+8 -3
appview/pages/templates/layouts/topbar.html
··· 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white drop-shadow-sm"> 3 <div class="container flex justify-between p-0"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> ··· 28 {{ didOrHandle .Did .Handle }} 29 </summary> 30 <div 31 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white border border-gray-200" 32 > 33 <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 34 <a href="/knots">knots</a> 35 <a href="/settings">settings</a> 36 - <a href="/logout" class="text-red-400 hover:text-red-700">logout</a> 37 </div> 38 </details> 39 {{ end }}
··· 1 {{ define "layouts/topbar" }} 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 <div class="container flex justify-between p-0"> 4 <div id="left-items"> 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> ··· 28 {{ didOrHandle .Did .Handle }} 29 </summary> 30 <div 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 > 33 <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 34 <a href="/knots">knots</a> 35 <a href="/settings">settings</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> 42 </div> 43 </details> 44 {{ end }}
+27 -23
appview/pages/templates/repo/blob.html
··· 15 {{ $lines := split .Contents }} 16 {{ $tot_lines := len $lines }} 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" }} 19 {{ $linkstyle := "no-underline hover:underline" }} 20 - <div class="pb-2 text-base"> 21 - <div class="flex justify-between"> 22 - <div id="breadcrumbs"> 23 {{ range $idx, $value := .BreadCrumbs }} 24 {{ if ne $idx (sub (len $.BreadCrumbs) 1) }} 25 <a 26 href="{{ index . 1 }}" 27 - class="text-bold text-gray-500 {{ $linkstyle }}" 28 >{{ index . 0 }}</a 29 > 30 / 31 {{ else }} 32 - <span class="text-bold text-gray-500" 33 >{{ index . 0 }}</span 34 > 35 {{ end }} 36 {{ end }} 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 }} 42 </div> 43 </div> 44 </div> 45 {{ if .IsBinary }} 46 - <p class="text-center text-gray-400"> 47 This is a binary file and will not be displayed. 48 </p> 49 {{ 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> 62 {{ end }} 63 </div> 64 {{ end }}
··· 15 {{ $lines := split .Contents }} 16 {{ $tot_lines := len $lines }} 17 {{ $tot_chars := len (printf "%d" $tot_lines) }} 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 {{ $linkstyle := "no-underline hover:underline" }} 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 {{ range $idx, $value := .BreadCrumbs }} 24 {{ if ne $idx (sub (len $.BreadCrumbs) 1) }} 25 <a 26 href="{{ index . 1 }}" 27 + class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}" 28 >{{ index . 0 }}</a 29 > 30 / 31 {{ else }} 32 + <span class="text-bold text-black dark:text-white" 33 >{{ index . 0 }}</span 34 > 35 {{ end }} 36 {{ end }} 37 </div> 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 }} 53 </div> 54 </div> 55 </div> 56 {{ if .IsBinary }} 57 + <p class="text-center text-gray-400 dark:text-gray-500"> 58 This is a binary file and will not be displayed. 59 </p> 60 {{ else }} 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> 66 {{ end }} 67 </div> 68 {{ end }}
+10 -27
appview/pages/templates/repo/commit.html
··· 4 5 {{ $repo := .RepoInfo.FullName }} 6 {{ $commit := .Diff.Commit }} 7 - {{ $stat := .Diff.Stat }} 8 - {{ $diff := .Diff.Diff }} 9 10 - <section class="commit"> 11 <div id="commit-message"> 12 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 13 <div> 14 <p class="pb-2">{{ index $messageParts 0 }}</p> 15 {{ if gt (len $messageParts) 1 }} 16 - <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 17 {{ end }} 18 </div> 19 </div> 20 21 <div class="flex items-center"> 22 - <p class="text-sm text-gray-500"> 23 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 24 25 {{ if $didOrHandle }} 26 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500">{{ $didOrHandle }}</a> 27 {{ else }} 28 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a> 29 {{ end }} 30 <span class="px-1 select-none before:content-['\00B7']"></span> 31 {{ timeFmt $commit.Author.When }} 32 <span class="px-1 select-none before:content-['\00B7']"></span> 33 - <span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span> 34 - <span class="px-1 select-none before:content-['\00B7']"></span> 35 </p> 36 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> 39 {{ if $commit.Parent }} 40 {{ 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> 42 {{ end }} 43 </p> 44 </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> 59 </section> 60 61 {{end}} 62 63 {{ define "repoAfter" }} 64 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 65 {{end}}
··· 4 5 {{ $repo := .RepoInfo.FullName }} 6 {{ $commit := .Diff.Commit }} 7 8 + <section class="commit dark:text-white"> 9 <div id="commit-message"> 10 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 11 <div> 12 <p class="pb-2">{{ index $messageParts 0 }}</p> 13 {{ if gt (len $messageParts) 1 }} 14 + <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p> 15 {{ end }} 16 </div> 17 </div> 18 19 <div class="flex items-center"> 20 + <p class="text-sm text-gray-500 dark:text-gray-300"> 21 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 22 23 {{ if $didOrHandle }} 24 + <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 25 {{ else }} 26 + <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 27 {{ end }} 28 <span class="px-1 select-none before:content-['\00B7']"></span> 29 {{ timeFmt $commit.Author.When }} 30 <span class="px-1 select-none before:content-['\00B7']"></span> 31 </p> 32 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> 35 {{ if $commit.Parent }} 36 {{ i "arrow-left" "w-3 h-3 mx-1" }} 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> 38 {{ end }} 39 </p> 40 </div> 41 + 42 </section> 43 44 {{end}} 45 46 {{ define "repoAfter" }} 47 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 48 {{end}}
+2 -21
appview/pages/templates/repo/empty.html
··· 2 3 {{ define "repoContent" }} 4 <main> 5 - <p class="text-center pt-5 text-gray-400"> 6 This is an empty repository. Push some commits here. 7 </p> 8 </main> 9 {{ end }} 10 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 - 32 {{ end }}
··· 2 3 {{ define "repoContent" }} 4 <main> 5 + <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 6 This is an empty repository. Push some commits here. 7 </p> 8 </main> 9 {{ end }} 10 11 {{ define "repoAfter" }} 12 + {{ template "repo/fragments/cloneInstructions" . }} 13 {{ end }}
+38
appview/pages/templates/repo/fork.html
···
··· 1 + {{ define "title" }}fork &middot; {{ .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
···
··· 1 + {{ define "repo/fragments/cloneInstructions" }} 2 + <section 3 + class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 4 + > 5 + <div class="flex flex-col gap-2"> 6 + <strong>push</strong> 7 + <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 8 + <code class="dark:text-gray-100" 9 + >git remote add origin 10 + git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 11 + > 12 + </div> 13 + </div> 14 + 15 + <div class="flex flex-col gap-2"> 16 + <strong>clone</strong> 17 + <div class="md:pl-4 flex flex-col gap-2"> 18 + <div class="flex items-center gap-3"> 19 + <span 20 + class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 21 + >HTTP</span 22 + > 23 + <div class="overflow-x-auto whitespace-nowrap flex-1"> 24 + <code class="dark:text-gray-100" 25 + >git clone 26 + https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 27 + > 28 + </div> 29 + </div> 30 + 31 + <div class="flex items-center gap-3"> 32 + <span 33 + class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 34 + >SSH</span 35 + > 36 + <div class="overflow-x-auto whitespace-nowrap flex-1"> 37 + <code class="dark:text-gray-100" 38 + >git clone 39 + git@{{ .RepoInfo.Knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 40 + > 41 + </div> 42 + </div> 43 + </div> 44 + </div> 45 + 46 + <p class="py-2 text-gray-500 dark:text-gray-400"> 47 + Note that for self-hosted knots, clone URLs may be different based 48 + on your setup. 49 + </p> 50 + </section> 51 + {{ end }}
+163
appview/pages/templates/repo/fragments/diff.html
···
··· 1 + {{ define "repo/fragments/diff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $commit := $diff.Commit }} 5 + {{ $stat := $diff.Stat }} 6 + {{ $fileTree := fileTree $diff.ChangedFiles }} 7 + {{ $diff := $diff.Diff }} 8 + 9 + {{ $this := $commit.This }} 10 + {{ $parent := $commit.Parent }} 11 + 12 + <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 13 + <div class="diff-stat"> 14 + <div class="flex gap-2 items-center"> 15 + <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 + {{ block "statPill" $stat }} {{ end }} 17 + </div> 18 + {{ block "fileTree" $fileTree }} {{ end }} 19 + </div> 20 + </section> 21 + 22 + {{ $last := sub (len $diff) 1 }} 23 + {{ range $idx, $hunk := $diff }} 24 + {{ with $hunk }} 25 + <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 26 + <div id="file-{{ .Name.New }}"> 27 + <div id="diff-file"> 28 + <details open> 29 + <summary class="list-none cursor-pointer sticky top-0"> 30 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 + <div class="flex gap-1 items-center"> 33 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 + {{ if .IsNew }} 35 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 36 + {{ else if .IsDelete }} 37 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 38 + {{ else if .IsCopy }} 39 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 40 + {{ else if .IsRename }} 41 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 42 + {{ else }} 43 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 44 + {{ end }} 45 + 46 + {{ block "statPill" .Stats }} {{ end }} 47 + </div> 48 + 49 + <div class="flex gap-2 items-center overflow-x-auto"> 50 + {{ if .IsDelete }} 51 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 + {{ .Name.Old }} 53 + </a> 54 + {{ else if (or .IsCopy .IsRename) }} 55 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 56 + {{ .Name.Old }} 57 + </a> 58 + {{ i "arrow-right" "w-4 h-4" }} 59 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 60 + {{ .Name.New }} 61 + </a> 62 + {{ else }} 63 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 + {{ .Name.New }} 65 + </a> 66 + {{ end }} 67 + </div> 68 + </div> 69 + 70 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 71 + <div id="right-side-items" class="p-2 flex items-center"> 72 + <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 73 + {{ if gt $idx 0 }} 74 + {{ $prev := index $diff (sub $idx 1) }} 75 + <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 76 + {{ end }} 77 + 78 + {{ if lt $idx $last }} 79 + {{ $next := index $diff (add $idx 1) }} 80 + <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 81 + {{ end }} 82 + </div> 83 + 84 + </div> 85 + </summary> 86 + 87 + <div class="transition-all duration-700 ease-in-out"> 88 + {{ if .IsDelete }} 89 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 90 + This file has been deleted. 91 + </p> 92 + {{ else if .IsCopy }} 93 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 + This file has been copied. 95 + </p> 96 + {{ else if .IsBinary }} 97 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 + This is a binary file and will not be displayed. 99 + </p> 100 + {{ else }} 101 + {{ $name := .Name.New }} 102 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 103 + {{- $oldStart := .OldPosition -}} 104 + {{- $newStart := .NewPosition -}} 105 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 106 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 107 + {{- $lineNrSepStyle1 := "" -}} 108 + {{- $lineNrSepStyle2 := "pr-2" -}} 109 + {{- range .Lines -}} 110 + {{- if eq .Op.String "+" -}} 111 + <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 112 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 113 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 114 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 115 + <div class="px-2">{{ .Line }}</div> 116 + </div> 117 + {{- $newStart = add64 $newStart 1 -}} 118 + {{- end -}} 119 + {{- if eq .Op.String "-" -}} 120 + <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 121 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 122 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 123 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 124 + <div class="px-2">{{ .Line }}</div> 125 + </div> 126 + {{- $oldStart = add64 $oldStart 1 -}} 127 + {{- end -}} 128 + {{- if eq .Op.String " " -}} 129 + <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 130 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 131 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 132 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 133 + <div class="px-2">{{ .Line }}</div> 134 + </div> 135 + {{- $newStart = add64 $newStart 1 -}} 136 + {{- $oldStart = add64 $oldStart 1 -}} 137 + {{- end -}} 138 + {{- end -}} 139 + {{- end -}}</div></div></pre> 140 + {{- end -}} 141 + </div> 142 + 143 + </details> 144 + 145 + </div> 146 + </div> 147 + </section> 148 + {{ end }} 149 + {{ end }} 150 + {{ end }} 151 + 152 + {{ define "statPill" }} 153 + <div class="flex items-center font-mono text-sm"> 154 + {{ if and .Insertions .Deletions }} 155 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 156 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 157 + {{ else if .Insertions }} 158 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 159 + {{ else if .Deletions }} 160 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 161 + {{ end }} 162 + </div> 163 + {{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
··· 1 + {{ define "repo/fragments/editRepoDescription" }} 2 + <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 + <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 + <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 + {{ i "check" "w-3 h-3" }} save 6 + </button> 7 + <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 + {{ i "x" "w-3 h-3" }} cancel 9 + </button> 10 + </form> 11 + {{ end }}
+27
appview/pages/templates/repo/fragments/filetree.html
···
··· 1 + {{ define "fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 5 + <span class="inline-flex items-center gap-2 "> 6 + {{ i "folder" "w-3 h-3 fill-current" }} 7 + <span class="text-black dark:text-white">{{ .Name }}</span> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ block "fileTree" $child }} {{ end }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 17 + <div class="flex items-center gap-2 pt-1"> 18 + {{ i "file" "w-3 h-3" }} 19 + <a href="#file-{{ .Path }}" class="text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ block "fileTree" $child }} {{ end }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
+143
appview/pages/templates/repo/fragments/interdiff.html
···
··· 1 + {{ define "repo/fragments/interdiff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $x := index . 1 }} 4 + {{ $fileTree := fileTree $x.AffectedFiles }} 5 + {{ $diff := $x.Files }} 6 + 7 + <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 8 + <div class="diff-stat"> 9 + <div class="flex gap-2 items-center"> 10 + <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 + </div> 12 + {{ block "fileTree" $fileTree }} {{ end }} 13 + </div> 14 + </section> 15 + 16 + {{ $last := sub (len $diff) 1 }} 17 + {{ range $idx, $hunk := $diff }} 18 + {{ with $hunk }} 19 + <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 20 + <div id="file-{{ .Name }}"> 21 + <div id="diff-file"> 22 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 23 + <summary class="list-none cursor-pointer sticky top-0"> 24 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 25 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 26 + <div class="flex gap-1 items-center" style="direction: ltr;"> 27 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 28 + {{ if .Status.IsOk }} 29 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 30 + {{ else if .Status.IsUnchanged }} 31 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 32 + {{ else if .Status.IsOnlyInOne }} 33 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 34 + {{ else if .Status.IsOnlyInTwo }} 35 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 36 + {{ else if .Status.IsRebased }} 37 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 38 + {{ else }} 39 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 40 + {{ end }} 41 + </div> 42 + 43 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 44 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 45 + {{ .Name }} 46 + </a> 47 + </div> 48 + </div> 49 + 50 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 51 + <div id="right-side-items" class="p-2 flex items-center"> 52 + <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 53 + {{ if gt $idx 0 }} 54 + {{ $prev := index $diff (sub $idx 1) }} 55 + <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 56 + {{ end }} 57 + 58 + {{ if lt $idx $last }} 59 + {{ $next := index $diff (add $idx 1) }} 60 + <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 61 + {{ end }} 62 + </div> 63 + 64 + </div> 65 + </summary> 66 + 67 + <div class="transition-all duration-700 ease-in-out"> 68 + {{ if .Status.IsUnchanged }} 69 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 70 + This file has not been changed. 71 + </p> 72 + {{ else if .Status.IsRebased }} 73 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 74 + This patch was likely rebased, as context lines do not match. 75 + </p> 76 + {{ else if .Status.IsError }} 77 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 78 + Failed to calculate interdiff for this file. 79 + </p> 80 + {{ else }} 81 + {{ $name := .Name }} 82 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 83 + {{- $oldStart := .OldPosition -}} 84 + {{- $newStart := .NewPosition -}} 85 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 86 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 87 + {{- $lineNrSepStyle1 := "" -}} 88 + {{- $lineNrSepStyle2 := "pr-2" -}} 89 + {{- range .Lines -}} 90 + {{- if eq .Op.String "+" -}} 91 + <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 92 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 93 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 94 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 95 + <div class="px-2">{{ .Line }}</div> 96 + </div> 97 + {{- $newStart = add64 $newStart 1 -}} 98 + {{- end -}} 99 + {{- if eq .Op.String "-" -}} 100 + <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 101 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 102 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 103 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 104 + <div class="px-2">{{ .Line }}</div> 105 + </div> 106 + {{- $oldStart = add64 $oldStart 1 -}} 107 + {{- end -}} 108 + {{- if eq .Op.String " " -}} 109 + <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 110 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 111 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 112 + <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 113 + <div class="px-2">{{ .Line }}</div> 114 + </div> 115 + {{- $newStart = add64 $newStart 1 -}} 116 + {{- $oldStart = add64 $oldStart 1 -}} 117 + {{- end -}} 118 + {{- end -}} 119 + {{- end -}}</div></div></pre> 120 + {{- end -}} 121 + </div> 122 + 123 + </details> 124 + 125 + </div> 126 + </div> 127 + </section> 128 + {{ end }} 129 + {{ end }} 130 + {{ end }} 131 + 132 + {{ define "statPill" }} 133 + <div class="flex items-center font-mono text-sm"> 134 + {{ if and .Insertions .Deletions }} 135 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 136 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 137 + {{ else if .Insertions }} 138 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 139 + {{ else if .Deletions }} 140 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 141 + {{ end }} 142 + </div> 143 + {{ end }}
+47
appview/pages/templates/repo/fragments/repoActions.html
···
··· 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
···
··· 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
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }} 2 3 - 4 {{ 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 }}"> 11 {{ end }} 12 - 13 14 {{ define "repoContent" }} 15 <main> 16 - {{ block "branchSelector" . }} {{ end }} 17 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> 18 - {{ block "fileTree" . }} {{ end }} 19 - {{ block "commitLog" . }} {{ end }} 20 </div> 21 </main> 22 {{ end }} 23 24 {{ 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> 68 {{ end }} 69 70 {{ 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" }} 74 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> 88 89 - <time class="text-xs text-gray-500" 90 - >{{ timeFmt .LastCommit.When }}</time 91 - > 92 </div> 93 - </div> 94 {{ end }} 95 - {{ end }} 96 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> 109 110 - <time class="text-xs text-gray-500" 111 - >{{ timeFmt .LastCommit.When }}</time 112 - > 113 </div> 114 - </div> 115 {{ end }} 116 - {{ end }} 117 - </div> 118 {{ end }} 119 - 120 121 {{ 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 }} 136 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 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 }} 181 <div 182 class="inline-block px-1 select-none after:content-['ยท']" 183 ></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> 194 {{ end }} 195 - 196 197 {{ define "repoAfter" }} 198 {{- 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}}"> 201 {{ if .Raw }} 202 - <pre>{{ .HTMLReadme }}</pre> 203 {{ else }} 204 {{ .HTMLReadme }} 205 {{ end }} ··· 207 </section> 208 {{- end -}} 209 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> 242 {{ end }}
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }} 2 3 {{ define "extrameta" }} 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 + /> 28 {{ end }} 29 30 {{ define "repoContent" }} 31 <main> 32 + {{ block "branchSelector" . }}{{ end }} 33 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> 34 + {{ block "fileTree" . }}{{ end }} 35 + {{ block "commitLog" . }}{{ end }} 36 </div> 37 </main> 38 {{ end }} 39 40 {{ define "branchSelector" }} 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> 84 {{ end }} 85 86 {{ define "fileTree" }} 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" }} 93 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> 107 108 + <time class="text-xs text-gray-500 dark:text-gray-400" 109 + >{{ timeFmt .LastCommit.When }}</time 110 + > 111 + </div> 112 </div> 113 + {{ end }} 114 {{ end }} 115 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> 128 129 + <time class="text-xs text-gray-500 dark:text-gray-400" 130 + >{{ timeFmt .LastCommit.When }}</time 131 + > 132 + </div> 133 </div> 134 + {{ end }} 135 {{ end }} 136 + </div> 137 {{ end }} 138 139 {{ define "commitLog" }} 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 }} 154 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> 173 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> 199 <div 200 class="inline-block px-1 select-none after:content-['ยท']" 201 ></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> 220 {{ end }} 221 222 {{ define "repoAfter" }} 223 {{- if .HTMLReadme }} 224 + <section 225 + class="mt-4 p-6 rounded bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto {{ if not .Raw }} 226 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 227 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 228 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 229 + {{ end }}" 230 + > 231 + <article class="{{ if .Raw }}whitespace-pre{{ end }}"> 232 {{ if .Raw }} 233 + <pre 234 + class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded" 235 + > 236 + {{ .HTMLReadme }}</pre 237 + > 238 {{ else }} 239 {{ .HTMLReadme }} 240 {{ end }} ··· 242 </section> 243 {{- end -}} 244 245 + {{ template "repo/fragments/cloneInstructions" . }} 246 {{ end }}
+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
···
··· 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
··· 1 - {{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot;{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <header class="pb-4"> 5 <h1 class="text-2xl"> 6 {{ .Issue.Title }} 7 - <span class="text-gray-500">#{{ .Issue.IssueId }}</span> 8 </h1> 9 </header> 10 11 - {{ $bgColor := "bg-gray-800" }} 12 {{ $icon := "ban" }} 13 {{ if eq .State "open" }} 14 - {{ $bgColor = "bg-green-600" }} 15 {{ $icon = "circle-dot" }} 16 {{ end }} 17 18 <section class="mt-2"> 19 <div class="inline-flex items-center gap-2"> 20 <div id="state" 21 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }} text-sm"> 22 {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 23 <span class="text-white">{{ .State }}</span> 24 </div> 25 - <span class="text-gray-500 text-sm"> 26 opened by 27 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 28 <a href="/{{ $owner }}" class="no-underline hover:underline" 29 >{{ $owner }}</a 30 > 31 <span class="px-1 select-none before:content-['\00B7']"></span> 32 - <time>{{ .Issue.Created | timeFmt }}</time> 33 </span> 34 </div> 35 36 {{ if .Issue.Body }} 37 - <article id="body" class="mt-4 prose"> 38 {{ .Issue.Body | markdown }} 39 </article> 40 {{ end }} ··· 42 {{ end }} 43 44 {{ define "repoAfter" }} 45 - {{ if gt (len .Comments) 0 }} 46 - <section id="comments" class="mt-8 space-y-4 relative"> 47 {{ range $index, $comment := .Comments }} 48 <div 49 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> 56 {{ 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> 79 </div> 80 {{ end }} 81 </section> 82 - {{ end }} 83 84 {{ block "newComment" . }} {{ end }} 85 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 {{ end }} 110 111 {{ define "newComment" }} 112 {{ 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"> 116 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 117 </div> 118 - <form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"> 119 <textarea 120 name="body" 121 - class="w-full p-2 rounded border border-gray-200" 122 - placeholder="Add to the discussion..." 123 ></textarea> 124 - <button type="submit" class="btn mt-2">comment</button> 125 <div id="issue-comment"></div> 126 - </form> 127 </div> 128 {{ 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> 131 <a href="/login" class="underline">login</a> to join the discussion 132 </div> 133 {{ end }}
··· 1 + {{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <header class="pb-4"> 5 <h1 class="text-2xl"> 6 {{ .Issue.Title }} 7 + <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 8 </h1> 9 </header> 10 11 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 12 {{ $icon := "ban" }} 13 {{ if eq .State "open" }} 14 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 15 {{ $icon = "circle-dot" }} 16 {{ end }} 17 18 <section class="mt-2"> 19 <div class="inline-flex items-center gap-2"> 20 <div id="state" 21 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 22 {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 23 <span class="text-white">{{ .State }}</span> 24 </div> 25 + <span class="text-gray-500 dark:text-gray-400 text-sm"> 26 opened by 27 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 28 <a href="/{{ $owner }}" class="no-underline hover:underline" 29 >{{ $owner }}</a 30 > 31 <span class="px-1 select-none before:content-['\00B7']"></span> 32 + <time title="{{ .Issue.Created | longTimeFmt }}"> 33 + {{ .Issue.Created | timeFmt }} 34 + </time> 35 </span> 36 </div> 37 38 {{ if .Issue.Body }} 39 + <article id="body" class="mt-8 prose dark:prose-invert"> 40 {{ .Issue.Body | markdown }} 41 </article> 42 {{ end }} ··· 44 {{ end }} 45 46 {{ define "repoAfter" }} 47 + <section id="comments" class="my-2 mt-2 space-y-2 relative"> 48 {{ range $index, $comment := .Comments }} 49 <div 50 id="comment-{{ .CommentId }}" 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> 54 {{ end }} 55 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 56 </div> 57 {{ end }} 58 </section> 59 60 {{ block "newComment" . }} {{ end }} 61 62 {{ end }} 63 64 {{ define "newComment" }} 65 {{ if .LoggedInUser }} 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"> 73 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 74 </div> 75 <textarea 76 + id="comment-textarea" 77 name="body" 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()" 81 ></textarea> 82 <div id="issue-comment"></div> 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> 178 </div> 179 + </form> 180 {{ else }} 181 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 182 <a href="/login" class="underline">login</a> to join the discussion 183 </div> 184 {{ end }}
+48 -10
appview/pages/templates/repo/issues/issues.html
··· 4 <div class="flex justify-between items-center"> 5 <p> 6 filtering 7 - <select class="border px-1 bg-white border-gray-200" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value"> 8 <option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option> 9 <option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option> 10 </select> ··· 13 <a 14 href="/{{ .RepoInfo.FullName }}/issues/new" 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> 18 </a> 19 </div> 20 <div class="error" id="issues"></div> ··· 23 {{ define "repoAfter" }} 24 <div class="flex flex-col gap-2 mt-2"> 25 {{ range .Issues }} 26 - <div class="rounded drop-shadow-sm bg-white px-6 py-4"> 27 <div class="pb-2"> 28 <a 29 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" ··· 33 <span class="text-gray-500">#{{ .IssueId }}</span> 34 </a> 35 </div> 36 - <p class="text-sm text-gray-500"> 37 - {{ $bgColor := "bg-gray-800" }} 38 {{ $icon := "ban" }} 39 {{ $state := "closed" }} 40 {{ if .Open }} 41 - {{ $bgColor = "bg-green-600" }} 42 {{ $icon = "circle-dot" }} 43 {{ $state = "open" }} 44 {{ end }} 45 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> 49 </span> 50 51 <span> ··· 64 {{ if eq .Metadata.CommentCount 1 }} 65 {{ $s = "" }} 66 {{ end }} 67 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500">{{ .Metadata.CommentCount }} comment{{$s}}</a> 68 </span> 69 </p> 70 </div> 71 {{ end }} 72 </div> 73 {{ end }}
··· 4 <div class="flex justify-between items-center"> 5 <p> 6 filtering 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 <option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option> 9 <option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option> 10 </select> ··· 13 <a 14 href="/{{ .RepoInfo.FullName }}/issues/new" 15 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"> 16 + {{ i "circle-plus" "w-4 h-4" }} 17 + <span>new</span> 18 </a> 19 </div> 20 <div class="error" id="issues"></div> ··· 23 {{ define "repoAfter" }} 24 <div class="flex flex-col gap-2 mt-2"> 25 {{ range .Issues }} 26 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 27 <div class="pb-2"> 28 <a 29 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" ··· 33 <span class="text-gray-500">#{{ .IssueId }}</span> 34 </a> 35 </div> 36 + <p class="text-sm text-gray-500 dark:text-gray-400"> 37 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 38 {{ $icon := "ban" }} 39 {{ $state := "closed" }} 40 {{ if .Open }} 41 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 42 {{ $icon = "circle-dot" }} 43 {{ $state = "open" }} 44 {{ end }} 45 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 dark:text-white" }} 48 + <span class="text-white dark:text-white">{{ $state }}</span> 49 </span> 50 51 <span> ··· 64 {{ if eq .Metadata.CommentCount 1 }} 65 {{ $s = "" }} 66 {{ end }} 67 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 68 </span> 69 </p> 70 </div> 71 {{ end }} 72 + </div> 73 + 74 + {{ block "pagination" . }} {{ end }} 75 + 76 + {{ end }} 77 + 78 + {{ define "pagination" }} 79 + <div class="flex justify-end mt-4 gap-2"> 80 + {{ $currentState := "closed" }} 81 + {{ if .FilteringByOpen }} 82 + {{ $currentState = "open" }} 83 + {{ end }} 84 + 85 + {{ if gt .Page.Offset 0 }} 86 + {{ $prev := .Page.Previous }} 87 + <a 88 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 89 + hx-boost="true" 90 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 91 + > 92 + {{ i "chevron-left" "w-4 h-4" }} 93 + previous 94 + </a> 95 + {{ else }} 96 + <div></div> 97 + {{ end }} 98 + 99 + {{ if eq (len .Issues) .Page.Limit }} 100 + {{ $next := .Page.Next }} 101 + <a 102 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 103 + hx-boost="true" 104 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 105 + > 106 + next 107 + {{ i "chevron-right" "w-4 h-4" }} 108 + </a> 109 + {{ end }} 110 </div> 111 {{ end }}
+1 -1
appview/pages/templates/repo/issues/new.html
··· 1 - {{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form
··· 1 + {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form
+17 -17
appview/pages/templates/repo/log.html
··· 5 {{ $commit := index .Commits 0 }} 6 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 7 <div> 8 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"> 9 <p class="pb-5">{{ index $messageParts 0 }}</p> 10 {{ if gt (len $messageParts) 1 }} 11 <p class="mt-1 text-sm cursor-text pb-5"> ··· 15 </a> 16 </div> 17 18 - <div class="text-sm text-gray-500"> 19 <span class="font-mono"> 20 <a 21 href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 22 - class="text-gray-500 no-underline hover:underline" 23 >{{ slice $commit.Hash.String }}</a 24 > 25 </span> ··· 29 {{ if $didOrHandle }} 30 <a 31 href="/{{ $didOrHandle }}" 32 - class="text-gray-500 no-underline hover:underline" 33 >{{ $didOrHandle }}</a 34 > 35 {{ else }} 36 <a 37 href="mailto:{{ $commit.Author.Email }}" 38 - class="text-gray-500 no-underline hover:underline" 39 >{{ $commit.Author.Name }}</a 40 > 41 {{ end }} ··· 51 {{ define "repoAfter" }} 52 <main> 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> 55 {{ $end := length .Commits }} 56 {{ $commits := subslice .Commits 1 $end }} 57 {{ range $commits }} 58 <div class="flex flex-row justify-between items-center"> 59 <div 60 - class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white" 61 > 62 <div id="commit-message"> 63 {{ $messageParts := splitN .Message "\n\n" 2 }} ··· 66 <div> 67 <a 68 href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 69 - class="inline no-underline hover:underline" 70 >{{ index $messageParts 0 }}</a 71 > 72 {{ if gt (len $messageParts) 1 }} 73 74 <button 75 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 76 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 77 > 78 {{ i "ellipsis" "w-3 h-3" }} ··· 81 </div> 82 {{ if gt (len $messageParts) 1 }} 83 <p 84 - class="hidden mt-1 text-sm cursor-text pb-2" 85 > 86 - {{ nl2br (unwrapText (index $messageParts 1)) }} 87 </p> 88 {{ end }} 89 </div> 90 </div> 91 </div> 92 93 - <div class="text-sm text-gray-500 mt-3"> 94 <span class="font-mono"> 95 <a 96 href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 97 - class="text-gray-500 no-underline hover:underline" 98 >{{ slice .Hash.String 0 8 }}</a 99 > 100 </span> ··· 106 {{ if $didOrHandle }} 107 <a 108 href="/{{ $didOrHandle }}" 109 - class="text-gray-500 no-underline hover:underline" 110 >{{ $didOrHandle }}</a 111 > 112 {{ else }} 113 <a 114 href="mailto:{{ .Author.Email }}" 115 - class="text-gray-500 no-underline hover:underline" 116 >{{ .Author.Name }}</a 117 > 118 {{ end }} ··· 131 <div class="flex justify-end mt-4 gap-2"> 132 {{ if gt .Page 1 }} 133 <a 134 - class="btn flex items-center gap-2 no-underline hover:no-underline" 135 hx-boost="true" 136 onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'" 137 > ··· 144 145 {{ if eq $commits_len 30 }} 146 <a 147 - class="btn flex items-center gap-2 no-underline hover:no-underline" 148 hx-boost="true" 149 onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'" 150 >
··· 5 {{ $commit := index .Commits 0 }} 6 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 7 <div> 8 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white"> 9 <p class="pb-5">{{ index $messageParts 0 }}</p> 10 {{ if gt (len $messageParts) 1 }} 11 <p class="mt-1 text-sm cursor-text pb-5"> ··· 15 </a> 16 </div> 17 18 + <div class="text-sm text-gray-500 dark:text-gray-400"> 19 <span class="font-mono"> 20 <a 21 href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 22 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 23 >{{ slice $commit.Hash.String }}</a 24 > 25 </span> ··· 29 {{ if $didOrHandle }} 30 <a 31 href="/{{ $didOrHandle }}" 32 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 33 >{{ $didOrHandle }}</a 34 > 35 {{ else }} 36 <a 37 href="mailto:{{ $commit.Author.Email }}" 38 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 39 >{{ $commit.Author.Name }}</a 40 > 41 {{ end }} ··· 51 {{ define "repoAfter" }} 52 <main> 53 <div id="commit-log" class="flex-1 relative"> 54 + <div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div> 55 {{ $end := length .Commits }} 56 {{ $commits := subslice .Commits 1 $end }} 57 {{ range $commits }} 58 <div class="flex flex-row justify-between items-center"> 59 <div 60 + class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white dark:bg-gray-800" 61 > 62 <div id="commit-message"> 63 {{ $messageParts := splitN .Message "\n\n" 2 }} ··· 66 <div> 67 <a 68 href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 69 + class="inline no-underline hover:underline dark:text-white" 70 >{{ index $messageParts 0 }}</a 71 > 72 {{ if gt (len $messageParts) 1 }} 73 74 <button 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 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 77 > 78 {{ i "ellipsis" "w-3 h-3" }} ··· 81 </div> 82 {{ if gt (len $messageParts) 1 }} 83 <p 84 + class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300" 85 > 86 + {{ nl2br (index $messageParts 1) }} 87 </p> 88 {{ end }} 89 </div> 90 </div> 91 </div> 92 93 + <div class="text-sm text-gray-500 dark:text-gray-400 mt-3"> 94 <span class="font-mono"> 95 <a 96 href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 97 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 98 >{{ slice .Hash.String 0 8 }}</a 99 > 100 </span> ··· 106 {{ if $didOrHandle }} 107 <a 108 href="/{{ $didOrHandle }}" 109 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 110 >{{ $didOrHandle }}</a 111 > 112 {{ else }} 113 <a 114 href="mailto:{{ .Author.Email }}" 115 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 116 >{{ .Author.Name }}</a 117 > 118 {{ end }} ··· 131 <div class="flex justify-end mt-4 gap-2"> 132 {{ if gt .Page 1 }} 133 <a 134 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 135 hx-boost="true" 136 onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'" 137 > ··· 144 145 {{ if eq $commits_len 30 }} 146 <a 147 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 148 hx-boost="true" 149 onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'" 150 >
+13 -13
appview/pages/templates/repo/new.html
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 - <p class="text-xl font-bold">Create a new repository</p> 6 </div> 7 - <div class="p-6 bg-white drop-shadow-sm rounded"> 8 <form hx-post="/repo/new" class="space-y-12" hx-swap="none"> 9 <div class="space-y-2"> 10 - <label for="name" class="-mb-1">Repository name</label> 11 <input 12 type="text" 13 id="name" 14 name="name" 15 required 16 - class="w-full max-w-md" 17 /> 18 - <p class="text-sm text-gray-500">All repositories are publicly visible.</p> 19 20 - <label for="branch">Default branch</label> 21 <input 22 type="text" 23 id="branch" 24 name="branch" 25 value="main" 26 required 27 - class="w-full max-w-md" 28 /> 29 30 - <label for="description">Description</label> 31 <input 32 type="text" 33 id="description" 34 name="description" 35 - class="w-full max-w-md" 36 /> 37 </div> 38 39 <fieldset class="space-y-3"> 40 - <legend>Select a knot</legend> 41 <div class="space-y-2"> 42 <div class="flex flex-col"> 43 {{ range .Knots }} ··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 - <span>{{ . }}</span> 53 </div> 54 {{ else }} 55 - <p>No knots available.</p> 56 {{ end }} 57 </div> 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> 60 </fieldset> 61 62 <div class="space-y-2">
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Create a new repository</p> 6 </div> 7 + <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 <form hx-post="/repo/new" class="space-y-12" hx-swap="none"> 9 <div class="space-y-2"> 10 + <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 <input 12 type="text" 13 id="name" 14 name="name" 15 required 16 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 /> 18 + <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 19 20 + <label for="branch" class="dark:text-white">Default branch</label> 21 <input 22 type="text" 23 id="branch" 24 name="branch" 25 value="main" 26 required 27 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 /> 29 30 + <label for="description" class="dark:text-white">Description</label> 31 <input 32 type="text" 33 id="description" 34 name="description" 35 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 /> 37 </div> 38 39 <fieldset class="space-y-3"> 40 + <legend class="dark:text-white">Select a knot</legend> 41 <div class="space-y-2"> 42 <div class="flex flex-col"> 43 {{ range .Knots }} ··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 + <span class="dark:text-white">{{ . }}</span> 53 </div> 54 {{ else }} 55 + <p class="dark:text-white">No knots available.</p> 56 {{ end }} 57 </div> 58 </div> 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 </fieldset> 61 62 <div class="space-y-2">
+90
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
··· 1 + {{ define "repo/pulls/fragments/pullActions" }} 2 + {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 3 + {{ $roundNumber := .RoundNumber }} 4 + 5 + {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 6 + {{ $isMerged := .Pull.State.IsMerged }} 7 + {{ $isClosed := .Pull.State.IsClosed }} 8 + {{ $isOpen := .Pull.State.IsOpen }} 9 + {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 10 + {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 11 + {{ $isLastRound := eq $roundNumber $lastIdx }} 12 + {{ $isSameRepoBranch := .Pull.IsBranchBased }} 13 + {{ $isUpToDate := .ResubmitCheck.No }} 14 + <div class="relative w-fit"> 15 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 16 + <button 17 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 18 + hx-target="#actions-{{$roundNumber}}" 19 + hx-swap="outerHtml" 20 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "message-square-plus" "w-4 h-4" }} 22 + <span>comment</span> 23 + </button> 24 + {{ if and $isPushAllowed $isOpen $isLastRound }} 25 + {{ $disabled := "" }} 26 + {{ if $isConflicted }} 27 + {{ $disabled = "disabled" }} 28 + {{ end }} 29 + <button 30 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 31 + hx-swap="none" 32 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 33 + class="btn p-2 flex items-center gap-2" {{ $disabled }}> 34 + {{ i "git-merge" "w-4 h-4" }} 35 + <span>merge</span> 36 + </button> 37 + {{ end }} 38 + 39 + {{ if and $isPullAuthor $isOpen $isLastRound }} 40 + {{ $disabled := "" }} 41 + {{ if $isUpToDate }} 42 + {{ $disabled = "disabled" }} 43 + {{ end }} 44 + <button id="resubmitBtn" 45 + {{ if not .Pull.IsPatchBased }} 46 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 47 + {{ else }} 48 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 49 + hx-target="#actions-{{$roundNumber}}" 50 + hx-swap="outerHtml" 51 + {{ end }} 52 + 53 + hx-disabled-elt="#resubmitBtn" 54 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" {{ $disabled }} 55 + 56 + {{ if $disabled }} 57 + title="Update this branch to resubmit this pull request" 58 + {{ else }} 59 + title="Resubmit this pull request" 60 + {{ end }} 61 + > 62 + {{ i "rotate-ccw" "w-4 h-4" }} 63 + <span>resubmit</span> 64 + </button> 65 + {{ end }} 66 + 67 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 68 + <button 69 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 70 + hx-swap="none" 71 + class="btn p-2 flex items-center gap-2"> 72 + {{ i "ban" "w-4 h-4" }} 73 + <span>close</span> 74 + </button> 75 + {{ end }} 76 + 77 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 78 + <button 79 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 80 + hx-swap="none" 81 + class="btn p-2 flex items-center gap-2"> 82 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 83 + <span>reopen</span> 84 + </button> 85 + {{ end }} 86 + </div> 87 + </div> 88 + {{ end }} 89 + 90 +
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareBranches" }} 2 + <div id="patch-upload"> 3 + <label for="targetBranch" class="dark:text-white" 4 + >select a branch</label 5 + > 6 + <div class="flex flex-wrap gap-2 items-center"> 7 + <select 8 + name="sourceBranch" 9 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 10 + > 11 + <option disabled selected>source branch</option> 12 + {{ range .Branches }} 13 + <option value="{{ .Reference.Name }}" class="py-1"> 14 + {{ .Reference.Name }} 15 + </option> 16 + {{ end }} 17 + </select> 18 + </div> 19 + </div> 20 + 21 + <p class="mt-4"> 22 + Title and description are optional; if left out, they will be extracted 23 + from the first commit. 24 + </p> 25 + {{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareForks" }} 2 + <div id="patch-upload"> 3 + <label for="forkSelect" class="dark:text-white" 4 + >select a fork to compare</label 5 + > 6 + <div class="flex flex-wrap gap-4 items-center mb-4"> 7 + <div class="flex flex-wrap gap-2 items-center"> 8 + <select 9 + id="forkSelect" 10 + name="fork" 11 + required 12 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 13 + hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches" 14 + hx-target="#branch-selection" 15 + hx-vals='{"fork": this.value}' 16 + hx-swap="innerHTML" 17 + onchange="document.getElementById('hiddenForkInput').value = this.value;" 18 + > 19 + <option disabled selected>select a fork</option> 20 + {{ range .Forks }} 21 + <option value="{{ .Name }}" class="py-1"> 22 + {{ .Name }} 23 + </option> 24 + {{ end }} 25 + </select> 26 + 27 + <input 28 + type="hidden" 29 + id="hiddenForkInput" 30 + name="fork" 31 + value="" 32 + /> 33 + </div> 34 + 35 + <div id="branch-selection"> 36 + <div class="text-sm text-gray-500 dark:text-gray-400"> 37 + Select a fork first to view available branches 38 + </div> 39 + </div> 40 + </div> 41 + </div> 42 + <p class="mt-4"> 43 + Title and description are optional; if left out, they will be extracted 44 + from the first commit. 45 + </p> 46 + {{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareForksBranches" }} 2 + <div class="flex flex-wrap gap-2 items-center"> 3 + <select 4 + name="sourceBranch" 5 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 6 + > 7 + <option disabled selected>source branch</option> 8 + {{ range .SourceBranches }} 9 + <option value="{{ .Reference.Name }}" class="py-1"> 10 + {{ .Reference.Name }} 11 + </option> 12 + {{ end }} 13 + </select> 14 + </div> 15 + {{ end }}
+70
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
··· 1 + {{ define "repo/pulls/fragments/pullHeader" }} 2 + <header class="pb-4"> 3 + <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title }} 5 + <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 + </h1> 7 + </header> 8 + 9 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 10 + {{ $icon := "ban" }} 11 + 12 + {{ if .Pull.State.IsOpen }} 13 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 14 + {{ $icon = "git-pull-request" }} 15 + {{ else if .Pull.State.IsMerged }} 16 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 17 + {{ $icon = "git-merge" }} 18 + {{ end }} 19 + 20 + <section class="mt-2"> 21 + <div class="flex items-center gap-2"> 22 + <div 23 + id="state" 24 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 25 + > 26 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 27 + <span class="text-white">{{ .Pull.State.String }}</span> 28 + </div> 29 + <span class="text-gray-500 dark:text-gray-400 text-sm"> 30 + opened by 31 + {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 + <a href="/{{ $owner }}" class="no-underline hover:underline" 33 + >{{ $owner }}</a 34 + > 35 + <span class="select-none before:content-['\00B7']"></span> 36 + <time>{{ .Pull.Created | timeFmt }}</time> 37 + <span class="select-none before:content-['\00B7']"></span> 38 + <span> 39 + targeting 40 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 41 + <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a> 42 + </span> 43 + </span> 44 + {{ if not .Pull.IsPatchBased }} 45 + <span>from 46 + {{ if .Pull.IsForkBased }} 47 + {{ if .Pull.PullSource.Repo }} 48 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a> 49 + {{ else }} 50 + <span class="italic">[deleted fork]</span> 51 + {{ end }} 52 + {{ end }} 53 + 54 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 55 + {{ .Pull.PullSource.Branch }} 56 + </span> 57 + </span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + 62 + {{ if .Pull.Body }} 63 + <article id="body" class="mt-8 prose dark:prose-invert"> 64 + {{ .Pull.Body | markdown }} 65 + </article> 66 + {{ end }} 67 + </section> 68 + 69 + 70 + {{ end }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
··· 1 + {{ define "repo/pulls/fragments/pullNewComment" }} 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 + class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 + <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 + </div> 8 + <form 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 + hx-swap="none" 11 + class="w-full flex flex-wrap gap-2"> 12 + <textarea 13 + name="body" 14 + class="w-full p-2 rounded border border-gray-200" 15 + placeholder="Add to the discussion..."></textarea> 16 + <button type="submit" class="btn flex items-center gap-2"> 17 + {{ i "message-square" "w-4 h-4" }} comment 18 + </button> 19 + <button 20 + type="button" 21 + class="btn flex items-center gap-2" 22 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 23 + hx-swap="outerHTML" 24 + hx-target="#pull-comment-card-{{ .RoundNumber }}"> 25 + {{ i "x" "w-4 h-4" }} 26 + <span>cancel</span> 27 + </button> 28 + <div id="pull-comment"></div> 29 + </form> 30 + </div> 31 + {{ end }} 32 +
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
··· 1 + {{ define "repo/pulls/fragments/pullPatchUpload" }} 2 + <div id="patch-upload"> 3 + <p> 4 + You can paste a <code>git diff</code> or a 5 + <code>git format-patch</code> patch series here. 6 + </p> 7 + <textarea 8 + hx-trigger="keyup changed delay:500ms, paste delay:500ms" 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch" 10 + hx-swap="none" 11 + name="patch" 12 + id="patch" 13 + rows="12" 14 + class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600" 15 + placeholder="diff --git a/file.txt b/file.txt 16 + index 1234567..abcdefg 100644 17 + --- a/file.txt 18 + +++ b/file.txt" 19 + ></textarea> 20 + </div> 21 + {{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
··· 1 + {{ define "repo/pulls/fragments/pullResubmit" }} 2 + <div 3 + id="resubmit-pull-card" 4 + class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2"> 5 + 6 + <div class="flex items-center gap-2 text-amber-500 dark:text-amber-50"> 7 + {{ i "pencil" "w-4 h-4" }} 8 + <span class="font-medium">resubmit your patch</span> 9 + </div> 10 + 11 + <div class="mt-2 text-sm text-gray-700 dark:text-gray-200"> 12 + You can update this patch to address any reviews. 13 + This will begin a new round of reviews, 14 + but you'll still be able to view your previous submissions and feedback. 15 + </div> 16 + 17 + <div class="mt-4 flex flex-col"> 18 + <form 19 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 20 + hx-swap="none" 21 + class="w-full flex flex-wrap gap-2"> 22 + <textarea 23 + name="patch" 24 + class="w-full p-2 mb-2" 25 + placeholder="Paste your updated patch here." 26 + rows="15" 27 + >{{.Pull.LatestPatch}}</textarea> 28 + <button 29 + type="submit" 30 + class="btn flex items-center gap-2" 31 + {{ if or .Pull.State.IsClosed }} 32 + disabled 33 + {{ end }}> 34 + {{ i "rotate-ccw" "w-4 h-4" }} 35 + <span>resubmit</span> 36 + </button> 37 + <button 38 + type="button" 39 + class="btn flex items-center gap-2" 40 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 41 + hx-swap="outerHTML" 42 + hx-target="#resubmit-pull-card"> 43 + {{ i "x" "w-4 h-4" }} 44 + <span>cancel</span> 45 + </button> 46 + </form> 47 + 48 + <div id="resubmit-error" class="error"></div> 49 + <div id="resubmit-success" class="success"></div> 50 + </div> 51 + </div> 52 + {{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
···
··· 1 + {{ define "title" }} 2 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "content" }} 6 + <section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white"> 7 + <header class="pb-2"> 8 + <div class="flex gap-3 items-center mb-3"> 9 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 10 + {{ i "arrow-left" "w-5 h-5" }} 11 + back 12 + </a> 13 + <span class="select-none before:content-['\00B7']"></span> 14 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} 15 + </div> 16 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 17 + {{ template "repo/pulls/fragments/pullHeader" . }} 18 + </header> 19 + </section> 20 + 21 + <section> 22 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 23 + </section> 24 + {{ end }} 25 +
+84 -38
appview/pages/templates/repo/pulls/new.html
··· 1 - {{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }} 2 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 <form 17 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 18 class="mt-6 space-y-6" 19 hx-swap="none" 20 > 21 <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" /> 25 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." 48 ></textarea> 49 - 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> 64 </div> 65 - <div id="pull" class="error"></div> 66 </form> 67 {{ end }}
··· 1 + {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form 5 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 6 class="mt-6 space-y-6" 7 hx-swap="none" 8 > 9 <div class="flex flex-col gap-4"> 10 + <label>configure your pull request</label> 11 12 + <p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p> 13 + <div class="pb-2"> 14 <select 15 + required 16 name="targetBranch" 17 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 18 > 19 + <option disabled selected>target branch</option> 20 {{ range .Branches }} 21 <option value="{{ .Reference.Name }}" class="py-1"> 22 {{ .Reference.Name }} 23 </option> 24 {{ end }} 25 </select> 26 + </div> 27 + 28 + <p>Next, choose a pull strategy.</p> 29 + <nav class="flex space-x-4 items-end"> 30 + <button 31 + type="button" 32 + class="px-3 py-2 pb-2 btn" 33 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 34 + hx-target="#patch-strategy" 35 + hx-swap="innerHTML" 36 + > 37 + paste patch 38 + </button> 39 + 40 + {{ if .RepoInfo.Roles.IsPushAllowed }} 41 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 42 + or 43 + </span> 44 + <button 45 + type="button" 46 + class="px-3 py-2 pb-2 btn" 47 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 48 + hx-target="#patch-strategy" 49 + hx-swap="innerHTML" 50 + > 51 + compare branches 52 + </button> 53 + {{ end }} 54 + 55 + 56 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 57 + or 58 + </span> 59 + <button 60 + type="button" 61 + class="px-3 py-2 pb-2 btn" 62 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 63 + hx-target="#patch-strategy" 64 + hx-swap="innerHTML" 65 + > 66 + compare forks 67 + </button> 68 + </nav> 69 + 70 + <section id="patch-strategy"> 71 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 72 + </section> 73 + 74 + <p id="patch-preview"></p> 75 + 76 + <div id="patch-error" class="error dark:text-red-300"></div> 77 + 78 + <div> 79 + <label for="title" class="dark:text-white">write a title</label> 80 + 81 + <input 82 + type="text" 83 + name="title" 84 + id="title" 85 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 86 + placeholder="One-line summary of your change." 87 + /> 88 + </div> 89 + 90 + <div> 91 + <label for="body" class="dark:text-white" 92 + >add a description</label 93 + > 94 + 95 <textarea 96 name="body" 97 id="body" 98 rows="6" 99 + class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 100 placeholder="Describe your change. Markdown is supported." 101 ></textarea> 102 </div> 103 + 104 + <div class="flex justify-start items-center gap-2 mt-4"> 105 + <button type="submit" class="btn flex items-center gap-2"> 106 + {{ i "git-pull-request-create" "w-4 h-4" }} 107 + create pull 108 + </button> 109 </div> 110 </div> 111 + <div id="pull" class="error dark:text-red-300"></div> 112 </form> 113 {{ end }}
+22 -83
appview/pages/templates/repo/pulls/patch.html
··· 1 {{ define "title" }} 2 - {{ $oneIndexedRound := add .Round 1 }} 3 - patch of {{ .Pull.Title }} &middot; round #{{ $oneIndexedRound }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 4 {{ end }} 5 6 {{ define "content" }} 7 - {{ $oneIndexedRound := add .Round 1 }} 8 - {{ $stat := .Diff.Stat }} 9 - <div class="rounded drop-shadow-sm bg-white py-4 px-6"> 10 - <header class="pb-2"> 11 - <div class="flex gap-3 items-center mb-3"> 12 - <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 13 - {{ i "arrow-left" "w-5 h-5" }} 14 - back 15 - </a> 16 - <span class="select-none before:content-['\00B7']"></span> 17 - round #{{ $oneIndexedRound }} 18 - </div> 19 - <div class="border-t border-gray-200 my-2"></div> 20 - <h1 class="text-2xl mt-3"> 21 - {{ .Pull.Title }} 22 - <span class="text-gray-500">#{{ .Pull.PullId }}</span> 23 - </h1> 24 - </header> 25 - 26 - {{ $bgColor := "bg-gray-800" }} 27 - {{ $icon := "ban" }} 28 - 29 - {{ if .Pull.State.IsOpen }} 30 - {{ $bgColor = "bg-green-600" }} 31 - {{ $icon = "git-pull-request" }} 32 - {{ else if .Pull.State.IsMerged }} 33 - {{ $bgColor = "bg-purple-600" }} 34 - {{ $icon = "git-merge" }} 35 - {{ end }} 36 - 37 - <section> 38 - <div class="flex items-center gap-2"> 39 - <div 40 - id="state" 41 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 42 - > 43 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 44 - <span class="text-white">{{ .Pull.State.String }}</span> 45 - </div> 46 - <span class="text-gray-500 text-sm"> 47 - opened by 48 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 49 - <a href="/{{ $owner }}" class="no-underline hover:underline" 50 - >{{ $owner }}</a 51 - > 52 - <span class="select-none before:content-['\00B7']"></span> 53 - <time>{{ .Pull.Created | timeFmt }}</time> 54 - <span class="select-none before:content-['\00B7']"></span> 55 - <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"> 57 - {{ .Pull.TargetBranch }} 58 - </span> 59 - </span> 60 - </span> 61 - </div> 62 - 63 - {{ if .Pull.Body }} 64 - <article id="body" class="mt-2 prose"> 65 - {{ .Pull.Body | markdown }} 66 - </article> 67 - {{ end }} 68 - </section> 69 - 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 - </div> 84 - 85 - <section> 86 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 87 - </section> 88 {{ end }}
··· 1 {{ define "title" }} 2 + patch of {{ .Pull.Title }} &middot; round #{{ .Round }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 {{ end }} 4 5 {{ define "content" }} 6 + <section> 7 + <section 8 + class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white" 9 + > 10 + <div class="flex gap-3 items-center mb-3"> 11 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 12 + {{ i "arrow-left" "w-5 h-5" }} 13 + back 14 + </a> 15 + <span class="select-none before:content-['\00B7']"></span> 16 + round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span> 17 + <span class="select-none before:content-['\00B7']"></span> 18 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch"> 19 + view raw 20 + </a> 21 + </div> 22 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 23 + {{ template "repo/pulls/fragments/pullHeader" . }} 24 + </section> 25 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 26 + </section> 27 {{ end }}
+120 -91
appview/pages/templates/repo/pulls/pull.html
··· 3 {{ end }} 4 5 {{ define "repoContent" }} 6 - <header class="pb-4"> 7 - <h1 class="text-2xl"> 8 - {{ .Pull.Title }} 9 - <span class="text-gray-500">#{{ .Pull.PullId }}</span> 10 - </h1> 11 - </header> 12 - 13 - {{ $bgColor := "bg-gray-800" }} 14 - {{ $icon := "ban" }} 15 - 16 - {{ if .Pull.State.IsOpen }} 17 - {{ $bgColor = "bg-green-600" }} 18 - {{ $icon = "git-pull-request" }} 19 - {{ else if .Pull.State.IsMerged }} 20 - {{ $bgColor = "bg-purple-600" }} 21 - {{ $icon = "git-merge" }} 22 - {{ end }} 23 - 24 - <section> 25 - <div class="flex items-center gap-2"> 26 - <div 27 - id="state" 28 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 29 - > 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .Pull.State.String }}</span> 32 - </div> 33 - <span class="text-gray-500 text-sm"> 34 - opened by 35 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 36 - <a href="/{{ $owner }}" class="no-underline hover:underline" 37 - >{{ $owner }}</a 38 - > 39 - <span class="select-none before:content-['\00B7']"></span> 40 - <time>{{ .Pull.Created | timeFmt }}</time> 41 - <span class="select-none before:content-['\00B7']"></span> 42 - <span>targeting branch 43 - <span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 44 - {{ .Pull.TargetBranch }} 45 - </span> 46 - </span> 47 - </span> 48 - </div> 49 - 50 - {{ if .Pull.Body }} 51 - <article id="body" class="mt-2 prose"> 52 - {{ .Pull.Body | markdown }} 53 - </article> 54 - {{ end }} 55 - </section> 56 - 57 {{ end }} 58 59 {{ define "repoAfter" }} ··· 72 {{ $targetBranch := .Pull.TargetBranch }} 73 {{ $repoName := .RepoInfo.FullName }} 74 {{ range $idx, $item := .Pull.Submissions }} 75 - {{ $diff := $item.AsNiceDiff $targetBranch }} 76 {{ with $item }} 77 - {{ $oneIndexedRound := add .RoundNumber 1 }} 78 <details {{ if eq $idx $lastIdx }}open{{ end }}> 79 - <summary id="round-#{{ $oneIndexedRound }}" class="list-none cursor-pointer"> 80 <div class="flex flex-wrap gap-2 items-center"> 81 <!-- round number --> 82 - <div class="rounded bg-white drop-shadow-sm px-3 py-2"> 83 - #{{ $oneIndexedRound }} 84 </div> 85 <!-- round summary --> 86 - <div class="rounded drop-shadow-sm bg-white p-2 text-gray-500"> 87 <span> 88 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 89 {{ $re := "re" }} ··· 93 <span class="hidden md:inline">{{$re}}submitted</span> 94 by <a href="/{{ $owner }}">{{ $owner }}</a> 95 <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> 97 <span class="select-none before:content-['ยท']"></span> 98 {{ $s := "s" }} 99 {{ if eq (len .Comments) 1 }} ··· 102 {{ len .Comments }} comment{{$s}} 103 </span> 104 </div> 105 - <!-- view patch --> 106 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 107 hx-boost="true" 108 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 109 {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 110 </a> 111 </div> 112 </summary> 113 - <div class="md:pl-12 flex flex-col gap-2 mt-2 relative"> 114 - {{ 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"> 118 - {{ $owner := index $.DidHandleMap .OwnerDid }} 119 <a href="/{{$owner}}">{{$owner}}</a> 120 <span class="before:content-['ยท']"></span> 121 - <a class="text-gray-500 hover:text-gray-500" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a> 122 </div> 123 - <div class="prose"> 124 - {{ .Body | markdown }} 125 </div> 126 </div> 127 {{ end }} 128 129 {{ if eq $lastIdx .RoundNumber }} 130 {{ block "mergeStatus" $ }} {{ end }} 131 {{ end }} 132 133 {{ if $.LoggedInUser }} 134 - {{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }} 135 {{ 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> 138 <a href="/login" class="underline">login</a> to join the discussion 139 </div> 140 {{ end }} 141 </div> 142 </details> 143 - <hr class="md:hidden"/> 144 {{ end }} 145 {{ end }} 146 {{ end }} 147 148 {{ define "mergeStatus" }} 149 {{ 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"> 153 {{ i "ban" "w-4 h-4" }} 154 <span class="font-medium">closed without merging</span 155 > 156 </div> 157 </div> 158 {{ 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"> 162 {{ i "git-merge" "w-4 h-4" }} 163 <span class="font-medium">pull request successfully merged</span 164 > 165 </div> 166 </div> 167 {{ 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"> 171 {{ i "triangle-alert" "w-4 h-4" }} 172 <span class="font-medium">{{ .MergeCheck.Error }}</span> 173 </div> 174 </div> 175 {{ 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"> 182 {{ range .MergeCheck.Conflicts }} 183 {{ if .Filename }} 184 <li class="flex items-center"> 185 - {{ i "file-warning" "w-3 h-3 mr-1.5 text-red-500" }} 186 <span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span> 187 </li> 188 {{ end }} ··· 191 </div> 192 </div> 193 {{ 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"> 197 {{ i "circle-check-big" "w-4 h-4" }} 198 <span class="font-medium">no conflicts, ready to merge</span> 199 </div> 200 </div> 201 {{ end }} 202 {{ end }}
··· 3 {{ end }} 4 5 {{ define "repoContent" }} 6 + {{ template "repo/pulls/fragments/pullHeader" . }} 7 {{ end }} 8 9 {{ define "repoAfter" }} ··· 22 {{ $targetBranch := .Pull.TargetBranch }} 23 {{ $repoName := .RepoInfo.FullName }} 24 {{ range $idx, $item := .Pull.Submissions }} 25 {{ with $item }} 26 <details {{ if eq $idx $lastIdx }}open{{ end }}> 27 + <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 28 <div class="flex flex-wrap gap-2 items-center"> 29 <!-- round number --> 30 + <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 31 + <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 32 </div> 33 <!-- round summary --> 34 + <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 35 <span> 36 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 37 {{ $re := "re" }} ··· 41 <span class="hidden md:inline">{{$re}}submitted</span> 42 by <a href="/{{ $owner }}">{{ $owner }}</a> 43 <span class="select-none before:content-['\00B7']"></span> 44 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a> 45 <span class="select-none before:content-['ยท']"></span> 46 {{ $s := "s" }} 47 {{ if eq (len .Comments) 1 }} ··· 50 {{ len .Comments }} comment{{$s}} 51 </span> 52 </div> 53 + 54 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 55 hx-boost="true" 56 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 57 {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 58 </a> 59 + {{ if not (eq .RoundNumber 0) }} 60 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 61 + hx-boost="true" 62 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 63 + {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">interdiff</span> 64 + </a> 65 + <span id="interdiff-error-{{.RoundNumber}}"></span> 66 + {{ end }} 67 </div> 68 </summary> 69 + 70 + {{ if .IsFormatPatch }} 71 + {{ $patches := .AsFormatPatch }} 72 + {{ $round := .RoundNumber }} 73 + <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 74 + <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 + {{ $s := "s" }} 76 + {{ if eq (len $patches) 1 }} 77 + {{ $s = "" }} 78 + {{ end }} 79 + <div class="group-open:hidden flex items-center gap-2 ml-2"> 80 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 81 + </div> 82 + <div class="hidden group-open:flex items-center gap-2 ml-2"> 83 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 84 + </div> 85 + </summary> 86 + {{ range $patches }} 87 + <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 88 + <div class="flex items-center gap-2"> 89 + {{ i "git-commit-horizontal" "w-4 h-4" }} 90 + <div class="text-sm text-gray-500 dark:text-gray-400"> 91 + {{ if not $.Pull.IsPatchBased }} 92 + {{ $fullRepo := $.RepoInfo.FullName }} 93 + {{ if $.Pull.IsForkBased }} 94 + {{ if $.Pull.PullSource.Repo }} 95 + {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 96 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 97 + {{ else }} 98 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 99 + {{ end }} 100 + {{ end }} 101 + {{ else }} 102 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 103 + {{ end }} 104 + </div> 105 + <div class="flex items-center"> 106 + <span>{{ .Title }}</span> 107 + {{ if gt (len .Body) 0 }} 108 + <button 109 + class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 110 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 111 + > 112 + {{ i "ellipsis" "w-3 h-3" }} 113 + </button> 114 + {{ end }} 115 + </div> 116 + </div> 117 + {{ if gt (len .Body) 0 }} 118 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 119 + {{ nl2br .Body }} 120 + </p> 121 + {{ end }} 122 + </div> 123 + {{ end }} 124 + </details> 125 + {{ end }} 126 + 127 + 128 + <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 129 + {{ range $cidx, $c := .Comments }} 130 + <div id="comment-{{$c.ID}}" class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 131 + {{ if gt $cidx 0 }} 132 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 133 + {{ end }} 134 + <div class="text-sm text-gray-500 dark:text-gray-400"> 135 + {{ $owner := index $.DidHandleMap $c.OwnerDid }} 136 <a href="/{{$owner}}">{{$owner}}</a> 137 <span class="before:content-['ยท']"></span> 138 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a> 139 </div> 140 + <div class="prose dark:prose-invert"> 141 + {{ $c.Body | markdown }} 142 </div> 143 </div> 144 {{ end }} 145 146 {{ if eq $lastIdx .RoundNumber }} 147 {{ block "mergeStatus" $ }} {{ end }} 148 + {{ block "resubmitStatus" $ }} {{ end }} 149 {{ end }} 150 151 {{ if $.LoggedInUser }} 152 + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }} 153 {{ else }} 154 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 155 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 156 <a href="/login" class="underline">login</a> to join the discussion 157 </div> 158 {{ end }} 159 </div> 160 </details> 161 + <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 162 {{ end }} 163 {{ end }} 164 {{ end }} 165 166 {{ define "mergeStatus" }} 167 {{ if .Pull.State.IsClosed }} 168 + <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 169 + <div class="flex items-center gap-2 text-black dark:text-white"> 170 {{ i "ban" "w-4 h-4" }} 171 <span class="font-medium">closed without merging</span 172 > 173 </div> 174 </div> 175 {{ else if .Pull.State.IsMerged }} 176 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 177 + <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 178 {{ i "git-merge" "w-4 h-4" }} 179 <span class="font-medium">pull request successfully merged</span 180 > 181 </div> 182 </div> 183 {{ else if and .MergeCheck .MergeCheck.Error }} 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="flex items-center gap-2 text-red-500 dark:text-red-300"> 186 {{ i "triangle-alert" "w-4 h-4" }} 187 <span class="font-medium">{{ .MergeCheck.Error }}</span> 188 </div> 189 </div> 190 {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 191 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 192 + <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 193 + <div class="flex items-center gap-2"> 194 + {{ i "triangle-alert" "w-4 h-4" }} 195 + <span class="font-medium">merge conflicts detected</span> 196 + </div> 197 + <ul class="space-y-1"> 198 {{ range .MergeCheck.Conflicts }} 199 {{ if .Filename }} 200 <li class="flex items-center"> 201 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 202 <span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span> 203 </li> 204 {{ end }} ··· 207 </div> 208 </div> 209 {{ else if .MergeCheck }} 210 + <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 211 + <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 212 {{ i "circle-check-big" "w-4 h-4" }} 213 <span class="font-medium">no conflicts, ready to merge</span> 214 </div> 215 </div> 216 {{ end }} 217 {{ end }} 218 + 219 + {{ define "resubmitStatus" }} 220 + {{ if .ResubmitCheck.Yes }} 221 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 222 + <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 223 + {{ i "triangle-alert" "w-4 h-4" }} 224 + <span class="font-medium">this branch has been updated, consider resubmitting</span> 225 + </div> 226 + </div> 227 + {{ end }} 228 + {{ end }} 229 + 230 + {{ define "commits" }} 231 + {{ end }}
+49 -15
appview/pages/templates/repo/pulls/pulls.html
··· 2 3 {{ define "repoContent" }} 4 <div class="flex justify-between items-center"> 5 - <p> 6 filtering 7 <select 8 - class="border px-1 bg-white border-gray-200" 9 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value" 10 > 11 <option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}> ··· 24 href="/{{ .RepoInfo.FullName }}/pulls/new" 25 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 26 > 27 - {{ i "git-pull-request" "w-4 h-4" }} 28 - <span>new pull request</span> 29 </a> 30 </div> 31 <div class="error" id="pulls"></div> ··· 34 {{ define "repoAfter" }} 35 <div class="flex flex-col gap-2 mt-2"> 36 {{ range .Pulls }} 37 - <div class="rounded drop-shadow-sm bg-white px-6 py-4"> 38 <div class="pb-2"> 39 - <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}"> 40 {{ .Title }} 41 - <span class="text-gray-500">#{{ .PullId }}</span> 42 </a> 43 </div> 44 - <p class="text-sm text-gray-500"> 45 - {{ $bgColor := "bg-gray-800" }} 46 {{ $icon := "ban" }} 47 48 {{ if .State.IsOpen }} 49 - {{ $bgColor = "bg-green-600" }} 50 {{ $icon = "git-pull-request" }} 51 {{ else if .State.IsMerged }} 52 - {{ $bgColor = "bg-purple-600" }} 53 {{ $icon = "git-merge" }} 54 {{ end }} 55 ··· 62 </span> 63 64 <span> 65 - {{ $owner := index $.DidHandleMap .OwnerDid }} 66 - <a href="/{{ $owner }}">{{ $owner }}</a> 67 </span> 68 69 <span class="before:content-['ยท']"> ··· 73 </span> 74 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"> 78 {{ .TargetBranch }} 79 </span> 80 </span> 81 </p> 82 </div>
··· 2 3 {{ define "repoContent" }} 4 <div class="flex justify-between items-center"> 5 + <p class="dark:text-white"> 6 filtering 7 <select 8 + class="border p-1 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600 dark:text-white" 9 onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value" 10 > 11 <option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}> ··· 24 href="/{{ .RepoInfo.FullName }}/pulls/new" 25 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 26 > 27 + {{ i "git-pull-request-create" "w-4 h-4" }} 28 + <span>new</span> 29 </a> 30 </div> 31 <div class="error" id="pulls"></div> ··· 34 {{ define "repoAfter" }} 35 <div class="flex flex-col gap-2 mt-2"> 36 {{ range .Pulls }} 37 + <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 px-6 py-4"> 38 <div class="pb-2"> 39 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 40 {{ .Title }} 41 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 42 </a> 43 </div> 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" }} 47 {{ $icon := "ban" }} 48 49 {{ if .State.IsOpen }} 50 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 51 {{ $icon = "git-pull-request" }} 52 {{ else if .State.IsMerged }} 53 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 54 {{ $icon = "git-merge" }} 55 {{ end }} 56 ··· 63 </span> 64 65 <span> 66 + <a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a> 67 </span> 68 69 <span class="before:content-['ยท']"> ··· 73 </span> 74 75 <span class="before:content-['ยท']"> 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 {{ .TargetBranch }} 79 </span> 80 + </span> 81 + {{ if not .IsPatchBased }} 82 + <span>from 83 + {{ if .IsForkBased }} 84 + {{ if .PullSource.Repo }} 85 + <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a> 86 + {{ else }} 87 + <span class="italic">[deleted fork]</span> 88 + {{ end }} 89 + {{ end }} 90 + 91 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 92 + {{ .PullSource.Branch }} 93 + </span> 94 + </span> 95 + {{ end }} 96 + <span class="before:content-['ยท']"> 97 + {{ $latestRound := .LastRoundNumber }} 98 + {{ $lastSubmission := index .Submissions $latestRound }} 99 + round 100 + <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 101 + #{{ .LastRoundNumber }} 102 + </span> 103 + {{ $commentCount := len $lastSubmission.Comments }} 104 + {{ $s := "s" }} 105 + {{ if eq $commentCount 1 }} 106 + {{ $s = "" }} 107 + {{ end }} 108 + 109 + {{ if eq $commentCount 0 }} 110 + awaiting comments 111 + {{ else }} 112 + recieved {{ len $lastSubmission.Comments}} comment{{$s}} 113 + {{ end }} 114 </span> 115 </p> 116 </div>
+52 -8
appview/pages/templates/repo/settings.html
··· 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase">Collaborators</header> 4 5 <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 6 {{ range .Collaborators }} 7 <div id="collaborator" class="mb-2"> 8 <a 9 href="/{{ didOrHandle .Did .Handle }}" 10 - class="no-underline hover:underline text-black" 11 > 12 {{ didOrHandle .Did .Handle }} 13 </a> 14 <div> 15 - <span class="text-sm text-gray-500"> 16 {{ .Role }} 17 </span> 18 </div> ··· 20 {{ end }} 21 </div> 22 23 - {{ if .IsCollaboratorInviteAllowed }} 24 - <h3>add collaborator</h3> 25 <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> 29 </form> 30 {{ end }} 31 {{ end }}
··· 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 {{ define "repoContent" }} 3 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 + Collaborators 5 + </header> 6 7 <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 {{ range .Collaborators }} 9 <div id="collaborator" class="mb-2"> 10 <a 11 href="/{{ didOrHandle .Did .Handle }}" 12 + class="no-underline hover:underline text-black dark:text-white" 13 > 14 {{ didOrHandle .Did .Handle }} 15 </a> 16 <div> 17 + <span class="text-sm text-gray-500 dark:text-gray-400"> 18 {{ .Role }} 19 </span> 20 </div> ··· 22 {{ end }} 23 </div> 24 25 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 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> 44 </form> 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 + 75 {{ end }}
+24 -22
appview/pages/templates/repo/tree.html
··· 17 {{ $containerstyle := "py-1" }} 18 {{ $linkstyle := "no-underline hover:underline" }} 19 20 - <div class="pb-2 text-base"> 21 - <div class="flex justify-between"> 22 - <div id="breadcrumbs"> 23 {{ range .BreadCrumbs }} 24 - <a href="{{ index . 1}}" class="text-bold text-gray-500 {{ $linkstyle }}">{{ index . 0 }}</a> / 25 {{ end }} 26 </div> 27 - <div id="dir-info"> 28 - <span class="text-gray-500 text-xs"> 29 - {{ $stats := .TreeStats }} 30 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 }} 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> 45 </div> 46 </div> 47 </div> ··· 55 {{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }} 56 </div> 57 </a> 58 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time> 59 </div> 60 </div> 61 {{ end }} ··· 70 {{ i "file" "w-3 h-3" }}{{ .Name }} 71 </div> 72 </a> 73 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time> 74 </div> 75 </div> 76 {{ end }}
··· 17 {{ $containerstyle := "py-1" }} 18 {{ $linkstyle := "no-underline hover:underline" }} 19 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 {{ range .BreadCrumbs }} 24 + <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> / 25 {{ end }} 26 </div> 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 }} 29 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 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 + 47 </div> 48 </div> 49 </div> ··· 57 {{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }} 58 </div> 59 </a> 60 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 61 </div> 62 </div> 63 {{ end }} ··· 72 {{ i "file" "w-3 h-3" }}{{ .Name }} 73 </div> 74 </a> 75 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 76 </div> 77 </div> 78 {{ end }}
+33 -33
appview/pages/templates/settings.html
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 - <p class="text-xl font-bold">Settings</p> 6 </div> 7 <div class="flex flex-col"> 8 {{ block "profile" . }} {{ end }} ··· 12 {{ end }} 13 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"> 18 {{ if .LoggedInUser.Handle }} 19 <dt class="font-bold">handle</dt> 20 <dd>@{{ .LoggedInUser.Handle }}</dd> ··· 28 {{ end }} 29 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> 34 <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 {{ range $index, $key := .PubKeys }} 36 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 <div class="flex flex-col gap-1"> 38 <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3" }} 40 - <p class="font-bold">{{ .Name }}</p> 41 </div> 42 - <p class="text-sm text-gray-500">added {{ .Created | timeFmt }}</p> 43 <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500">{{ .Key }}</code> 45 </div> 46 </div> 47 <button 48 - class="btn text-red-500 hover:text-red-700" 49 title="Delete key" 50 hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"> ··· 66 name="name" 67 placeholder="key name" 68 required 69 - class="w-full"/> 70 71 <input 72 id="key" 73 name="key" 74 placeholder="ssh-rsa AAAAAA..." 75 required 76 - class="w-full"/> 77 78 - <button class="btn" type="submit">add key</button> 79 80 - <div id="settings-keys" class="error"></div> 81 </form> 82 </section> 83 {{ end }} 84 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> 89 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 {{ range $index, $email := .Emails }} 91 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 92 <div class="flex flex-col gap-2"> 93 <div class="inline-flex items-center gap-4"> 94 - {{ i "mail" "w-3 h-3" }} 95 - <p class="font-bold">{{ .Address }}</p> 96 <div class="inline-flex items-center gap-1"> 97 {{ if .Verified }} 98 - <span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span> 99 {{ else }} 100 - <span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span> 101 {{ end }} 102 {{ if .Primary }} 103 - <span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span> 104 {{ end }} 105 </div> 106 </div> 107 - <p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p> 108 </div> 109 <div class="flex gap-2 items-center"> 110 {{ if not .Verified }} 111 <button 112 - class="btn flex gap-2" 113 hx-post="/settings/emails/verify/resend" 114 hx-swap="none" 115 href="#" ··· 120 {{ end }} 121 {{ if and (not .Primary) .Verified }} 122 <a 123 - class="text-sm" 124 hx-post="/settings/emails/primary" 125 hx-swap="none" 126 href="#" ··· 132 <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 133 <input type="hidden" name="email" value="{{ .Address }}"> 134 <button 135 - class="btn text-red-500 hover:text-red-700" 136 title="Delete email" 137 type="submit"> 138 {{ i "trash-2" "w-5 h-5" }} ··· 155 name="email" 156 placeholder="your@email.com" 157 required 158 - class="w-full"/> 159 160 - <button class="btn" type="submit">add email</button> 161 162 - <div id="settings-emails-error" class="error"></div> 163 - <div id="settings-emails-success" class="success"></div> 164 165 </form> 166 </section> 167 - {{ end }}
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 </div> 7 <div class="flex flex-col"> 8 {{ block "profile" . }} {{ end }} ··· 12 {{ end }} 13 14 {{ define "profile" }} 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 {{ if .LoggedInUser.Handle }} 19 <dt class="font-bold">handle</dt> 20 <dd>@{{ .LoggedInUser.Handle }}</dd> ··· 28 {{ end }} 29 30 {{ define "keys" }} 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 <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 {{ range $index, $key := .PubKeys }} 36 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 <div class="flex flex-col gap-1"> 38 <div class="inline-flex items-center gap-4"> 39 + {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 + <p class="font-bold dark:text-white">{{ .Name }}</p> 41 </div> 42 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p> 43 <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 + <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 </div> 46 </div> 47 <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2" 49 title="Delete key" 50 hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?"> ··· 66 name="name" 67 placeholder="key name" 68 required 69 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 70 71 <input 72 id="key" 73 name="key" 74 placeholder="ssh-rsa AAAAAA..." 75 required 76 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 77 78 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button> 79 80 + <div id="settings-keys" class="error dark:text-red-400"></div> 81 </form> 82 </section> 83 {{ end }} 84 85 {{ define "emails" }} 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 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 {{ range $index, $email := .Emails }} 91 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 92 <div class="flex flex-col gap-2"> 93 <div class="inline-flex items-center gap-4"> 94 + {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 95 + <p class="font-bold dark:text-white">{{ .Address }}</p> 96 <div class="inline-flex items-center gap-1"> 97 {{ if .Verified }} 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 {{ else }} 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 {{ end }} 102 {{ if .Primary }} 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 {{ end }} 105 </div> 106 </div> 107 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 108 </div> 109 <div class="flex gap-2 items-center"> 110 {{ if not .Verified }} 111 <button 112 + class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 113 hx-post="/settings/emails/verify/resend" 114 hx-swap="none" 115 href="#" ··· 120 {{ end }} 121 {{ if and (not .Primary) .Verified }} 122 <a 123 + class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 124 hx-post="/settings/emails/primary" 125 hx-swap="none" 126 href="#" ··· 132 <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 133 <input type="hidden" name="email" value="{{ .Address }}"> 134 <button 135 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 136 title="Delete email" 137 type="submit"> 138 {{ i "trash-2" "w-5 h-5" }} ··· 155 name="email" 156 placeholder="your@email.com" 157 required 158 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 159 160 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button> 161 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 165 </form> 166 </section> 167 + {{ end }}
+22 -14
appview/pages/templates/timeline.html
··· 17 {{ end }} 18 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"> 21 <div class="font-bold italic text-4xl mb-4"> 22 tangled 23 </div> 24 <div class="italic text-lg"> 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>. 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 </div> 29 </div> ··· 32 {{ define "timeline" }} 33 <div> 34 <div class="p-6"> 35 - <p class="text-xl font-bold">Timeline</p> 36 </div> 37 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> 40 {{ range .Timeline }} 41 - <div class="px-6 py-2 bg-white rounded drop-shadow-sm w-fit"> 42 {{ if .Repo }} 43 {{ $userHandle := index $.DidHandleMap .Repo.Did }} 44 <div class="flex items-center"> 45 - <p class="text-gray-600"> 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> 50 </p> 51 </div> 52 {{ else if .Follow }} 53 {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 54 {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 55 <div class="flex items-center"> 56 - <p class="text-gray-600"> 57 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 58 followed 59 <a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a> 60 - <time class="text-gray-700 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time> 61 </p> 62 </div> 63 {{ else if .Star }} 64 {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 65 {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 66 <div class="flex items-center"> 67 - <p class="text-gray-600"> 68 <a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a> 69 starred 70 <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> 72 </p> 73 </div> 74 {{ end }} ··· 77 </div> 78 </div> 79 {{ end }} 80 -
··· 17 {{ end }} 18 19 {{ define "hero" }} 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 <div class="font-bold italic text-4xl mb-4"> 22 tangled 23 </div> 24 <div class="italic text-lg"> 25 tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a> 26 + <p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a>or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>. 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 </div> 29 </div> ··· 32 {{ define "timeline" }} 33 <div> 34 <div class="p-6"> 35 + <p class="text-xl font-bold dark:text-white">Timeline</p> 36 </div> 37 38 <div class="flex flex-col gap-3 relative"> 39 + <div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div> 40 {{ range .Timeline }} 41 + <div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"> 42 {{ if .Repo }} 43 {{ $userHandle := index $.DidHandleMap .Repo.Did }} 44 <div class="flex items-center"> 45 + <p class="text-gray-600 dark:text-gray-300"> 46 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 47 + {{ if .Source }} 48 + forked 49 + <a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline"> 50 + {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }} 51 + </a> 52 + to 53 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 54 + {{ else }} 55 + created 56 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 57 + {{ end }} 58 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time> 59 </p> 60 </div> 61 {{ else if .Follow }} 62 {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 63 {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 64 <div class="flex items-center"> 65 + <p class="text-gray-600 dark:text-gray-300"> 66 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 67 followed 68 <a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a> 69 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time> 70 </p> 71 </div> 72 {{ else if .Star }} 73 {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 74 {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 75 <div class="flex items-center"> 76 + <p class="text-gray-600 dark:text-gray-300"> 77 <a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a> 78 starred 79 <a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a> 80 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time> 81 </p> 82 </div> 83 {{ end }} ··· 86 </div> 87 </div> 88 {{ end }}
+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 }}
+15 -7
appview/pages/templates/user/login.html
··· 1 {{ define "user/login" }} 2 <!doctype html> 3 - <html lang="en"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 - <link rel="stylesheet" href="/static/tw.css" type="text/css" /> 12 <title>login</title> 13 </head> 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"> 17 tangled 18 </h1> 19 - <h2 class="text-center text-xl italic"> 20 tightly-knit social coding. 21 </h2> 22 <form ··· 27 > 28 <div class="flex flex-col"> 29 <label for="handle">handle</label> 30 - <input type="text" id="handle" name="handle" required /> 31 <span class="text-xs text-gray-500 mt-1"> 32 You need to use your 33 <a href="https://bsky.app">Bluesky</a> handle to log ··· 41 type="password" 42 id="app_password" 43 name="app_password" 44 required 45 /> 46 <span class="text-xs text-gray-500 mt-1"> ··· 57 class="btn w-full my-2 mt-6" 58 type="submit" 59 id="login-button" 60 > 61 <span>login</span> 62 </button> 63 </form> 64 <p class="text-sm text-gray-500"> 65 - Join our IRC channel: 66 <a href="https://web.libera.chat/#tangled" 67 ><code>#tangled</code> on Libera Chat</a 68 >.
··· 1 {{ define "user/login" }} 2 <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 <title>login</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-7xl px-6 -mt-4"> 16 + <h1 class="text-center text-2xl font-semibold italic dark:text-white"> 17 tangled 18 </h1> 19 + <h2 class="text-center text-xl italic dark:text-white"> 20 tightly-knit social coding. 21 </h2> 22 <form ··· 27 > 28 <div class="flex flex-col"> 29 <label for="handle">handle</label> 30 + <input 31 + type="text" 32 + id="handle" 33 + name="handle" 34 + tabindex="1" 35 + required 36 + /> 37 <span class="text-xs text-gray-500 mt-1"> 38 You need to use your 39 <a href="https://bsky.app">Bluesky</a> handle to log ··· 47 type="password" 48 id="app_password" 49 name="app_password" 50 + tabindex="2" 51 required 52 /> 53 <span class="text-xs text-gray-500 mt-1"> ··· 64 class="btn w-full my-2 mt-6" 65 type="submit" 66 id="login-button" 67 + tabindex="3" 68 > 69 <span>login</span> 70 </button> 71 </form> 72 <p class="text-sm text-gray-500"> 73 + Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel: 74 <a href="https://web.libera.chat/#tangled" 75 ><code>#tangled</code> on Libera Chat</a 76 >.
+240 -27
appview/pages/templates/user/profile.html
··· 1 {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 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 }} 7 </div> 8 9 - <div class="md:col-span-3"> 10 - {{ block "ownRepos" . }}{{ end }} 11 - {{ block "collaboratingRepos" . }}{{ end }} 12 </div> 13 - </div> 14 {{ end }} 15 16 {{ define "profileCard" }} 17 - <div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit"> 18 <div class="flex justify-center items-center"> 19 {{ if .AvatarUri }} 20 - <img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" /> 21 {{ end }} 22 </div> 23 - <p class="text-xl font-bold text-center"> 24 - {{ truncateAt30 (didOrHandle .UserDid .UserHandle) }} 25 </p> 26 - <div class="text-sm text-center"> 27 <span>{{ .ProfileStats.Followers }} followers</span> 28 <div 29 class="inline-block px-1 select-none after:content-['ยท']" ··· 32 </div> 33 34 {{ if ne .FollowStatus.String "IsSelf" }} 35 - {{ template "fragments/follow" . }} 36 {{ end }} 37 </div> 38 {{ end }} 39 40 {{ 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"> 43 {{ range .Repos }} 44 <div 45 id="repo-card" 46 - class="py-4 px-6 drop-shadow-sm rounded bg-white" 47 > 48 - <div id="repo-card-name" class="font-medium"> 49 <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 50 >{{ .Name }}</a 51 > 52 </div> 53 {{ if .Description }} 54 - <div class="text-gray-600 text-sm"> 55 {{ .Description }} 56 </div> 57 {{ end }} ··· 68 </div> 69 </div> 70 {{ else }} 71 - <p class="px-6">This user does not have any repos yet.</p> 72 {{ end }} 73 </div> 74 - {{ end }} 75 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"> 79 {{ range .CollaboratingRepos }} 80 <div 81 id="repo-card" 82 - class="py-4 px-6 drop-shadow-sm rounded bg-white flex flex-col" 83 > 84 - <div id="repo-card-name" class="font-medium"> 85 <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 86 {{ index $.DidHandleMap .Did }}/{{ .Name }} 87 </a> 88 </div> 89 {{ if .Description }} 90 - <div class="text-gray-600 text-sm"> 91 {{ .Description }} 92 </div> 93 {{ end }} ··· 102 </div> 103 </div> 104 {{ else }} 105 - <p class="px-6">This user is not collaborating.</p> 106 {{ end }} 107 </div> 108 {{ end }}
··· 1 {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 3 {{ define "content" }} 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 }} 46 </div> 47 48 + {{ end }} 49 + {{ else }} 50 + <p class="dark:text-white">This user does not have any activity yet.</p> 51 + {{ end }} 52 + {{ end }} 53 + </div> 54 + {{ end }} 55 + 56 + {{ define "repoEvents" }} 57 + {{ $items := index . 0 }} 58 + {{ $handleMap := index . 1 }} 59 + 60 + {{ if gt (len $items) 0 }} 61 + <details> 62 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 63 + <div class="flex flex-wrap items-center gap-2"> 64 + {{ i "book-plus" "w-4 h-4" }} 65 + created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 66 + </div> 67 + </summary> 68 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 69 + {{ range $items }} 70 + <div class="flex flex-wrap items-center gap-2"> 71 + <span class="text-gray-500 dark:text-gray-400"> 72 + {{ if .Source }} 73 + {{ i "git-fork" "w-4 h-4" }} 74 + {{ else }} 75 + {{ i "book-plus" "w-4 h-4" }} 76 + {{ end }} 77 + </span> 78 + <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 79 + {{- .Repo.Name -}} 80 + </a> 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-white bg-gray-800 dark:bg-gray-700"> 183 + {{$stats.Closed}} closed 184 + </span> 185 + {{ end }} 186 + 187 </div> 188 + </summary> 189 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 190 + {{ range $items }} 191 + {{ $repoOwner := index $handleMap .Repo.Did }} 192 + {{ $repoName := .Repo.Name }} 193 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 194 + 195 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 196 + {{ if .State.IsOpen }} 197 + <span class="text-green-600 dark:text-green-500"> 198 + {{ i "git-pull-request" "w-4 h-4" }} 199 + </span> 200 + {{ else if .State.IsMerged }} 201 + <span class="text-purple-600 dark:text-purple-500"> 202 + {{ i "git-merge" "w-4 h-4" }} 203 + </span> 204 + {{ else }} 205 + <span class="text-gray-600 dark:text-gray-300"> 206 + {{ i "git-pull-request-closed" "w-4 h-4" }} 207 + </span> 208 + {{ end }} 209 + <div class="flex-none min-w-8 text-right"> 210 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 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 }} 226 {{ end }} 227 228 {{ define "profileCard" }} 229 + <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 230 <div class="flex justify-center items-center"> 231 {{ if .AvatarUri }} 232 + <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 233 {{ end }} 234 </div> 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 }} 240 </p> 241 + <div class="text-sm text-center dark:text-gray-300"> 242 <span>{{ .ProfileStats.Followers }} followers</span> 243 <div 244 class="inline-block px-1 select-none after:content-['ยท']" ··· 247 </div> 248 249 {{ if ne .FollowStatus.String "IsSelf" }} 250 + {{ template "user/fragments/follow" . }} 251 {{ end }} 252 </div> 253 {{ end }} 254 255 {{ define "ownRepos" }} 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"> 258 {{ range .Repos }} 259 <div 260 id="repo-card" 261 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800" 262 > 263 + <div id="repo-card-name" class="font-medium dark:text-white"> 264 <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 265 >{{ .Name }}</a 266 > 267 </div> 268 {{ if .Description }} 269 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 270 {{ .Description }} 271 </div> 272 {{ end }} ··· 283 </div> 284 </div> 285 {{ else }} 286 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 {{ end }} 288 </div> 289 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"> 292 {{ range .CollaboratingRepos }} 293 <div 294 id="repo-card" 295 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col" 296 > 297 + <div id="repo-card-name" class="font-medium dark:text-white"> 298 <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 299 {{ index $.DidHandleMap .Did }}/{{ .Name }} 300 </a> 301 </div> 302 {{ if .Description }} 303 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 304 {{ .Description }} 305 </div> 306 {{ end }} ··· 315 </div> 316 </div> 317 {{ else }} 318 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 319 {{ end }} 320 </div> 321 {{ end }}
+31
appview/pagination/page.go
···
··· 1 + package pagination 2 + 3 + type Page struct { 4 + Offset int // where to start from 5 + Limit int // number of items in a page 6 + } 7 + 8 + func FirstPage() Page { 9 + return Page{ 10 + Offset: 0, 11 + Limit: 10, 12 + } 13 + } 14 + 15 + func (p Page) Previous() Page { 16 + if p.Offset-p.Limit < 0 { 17 + return FirstPage() 18 + } else { 19 + return Page{ 20 + Offset: p.Offset - p.Limit, 21 + Limit: p.Limit, 22 + } 23 + } 24 + } 25 + 26 + func (p Page) Next() Page { 27 + return Page{ 28 + Offset: p.Offset + p.Limit, 29 + Limit: p.Limit, 30 + } 31 + }
+451
appview/settings/settings.go
···
··· 1 + package settings 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/auth" 17 + "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/email" 19 + "tangled.sh/tangled.sh/core/appview/middleware" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + "github.com/gliderlabs/ssh" 25 + "github.com/google/uuid" 26 + ) 27 + 28 + type Settings struct { 29 + Db *db.DB 30 + Auth *auth.Auth 31 + Pages *pages.Pages 32 + Config *appview.Config 33 + } 34 + 35 + func (s *Settings) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Use(middleware.AuthMiddleware(s.Auth)) 39 + 40 + r.Get("/", s.settings) 41 + 42 + r.Route("/keys", func(r chi.Router) { 43 + r.Put("/", s.keys) 44 + r.Delete("/", s.keys) 45 + }) 46 + 47 + r.Route("/emails", func(r chi.Router) { 48 + r.Put("/", s.emails) 49 + r.Delete("/", s.emails) 50 + r.Get("/verify", s.emailsVerify) 51 + r.Post("/verify/resend", s.emailsVerifyResend) 52 + r.Post("/primary", s.emailsPrimary) 53 + }) 54 + 55 + return r 56 + } 57 + 58 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 59 + user := s.Auth.GetUser(r) 60 + pubKeys, err := db.GetPublicKeys(s.Db, user.Did) 61 + if err != nil { 62 + log.Println(err) 63 + } 64 + 65 + emails, err := db.GetAllEmails(s.Db, user.Did) 66 + if err != nil { 67 + log.Println(err) 68 + } 69 + 70 + s.Pages.Settings(w, pages.SettingsParams{ 71 + LoggedInUser: user, 72 + PubKeys: pubKeys, 73 + Emails: emails, 74 + }) 75 + } 76 + 77 + // buildVerificationEmail creates an email.Email struct for verification emails 78 + func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email { 79 + verifyURL := s.verifyUrl(did, emailAddr, code) 80 + 81 + return email.Email{ 82 + APIKey: s.Config.ResendApiKey, 83 + From: "noreply@notifs.tangled.sh", 84 + To: emailAddr, 85 + Subject: "Verify your Tangled email", 86 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 87 + ` + verifyURL, 88 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 89 + <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 90 + } 91 + } 92 + 93 + // sendVerificationEmail handles the common logic for sending verification emails 94 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 95 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 96 + 97 + err := email.SendEmail(emailToSend) 98 + if err != nil { 99 + log.Printf("sending email: %s", err) 100 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 101 + return err 102 + } 103 + 104 + return nil 105 + } 106 + 107 + func (s *Settings) emails(w http.ResponseWriter, r *http.Request) { 108 + switch r.Method { 109 + case http.MethodGet: 110 + s.Pages.Notice(w, "settings-emails", "Unimplemented.") 111 + log.Println("unimplemented") 112 + return 113 + case http.MethodPut: 114 + did := s.Auth.GetDid(r) 115 + emAddr := r.FormValue("email") 116 + emAddr = strings.TrimSpace(emAddr) 117 + 118 + if !email.IsValidEmail(emAddr) { 119 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 120 + return 121 + } 122 + 123 + // check if email already exists in database 124 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 125 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 126 + log.Printf("checking for existing email: %s", err) 127 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 128 + return 129 + } 130 + 131 + if err == nil { 132 + if existingEmail.Verified { 133 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 134 + return 135 + } 136 + 137 + s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 138 + return 139 + } 140 + 141 + code := uuid.New().String() 142 + 143 + // Begin transaction 144 + tx, err := s.Db.Begin() 145 + if err != nil { 146 + log.Printf("failed to start transaction: %s", err) 147 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 148 + return 149 + } 150 + defer tx.Rollback() 151 + 152 + if err := db.AddEmail(tx, db.Email{ 153 + Did: did, 154 + Address: emAddr, 155 + Verified: false, 156 + VerificationCode: code, 157 + }); err != nil { 158 + log.Printf("adding email: %s", err) 159 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 160 + return 161 + } 162 + 163 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 164 + return 165 + } 166 + 167 + // Commit transaction 168 + if err := tx.Commit(); err != nil { 169 + log.Printf("failed to commit transaction: %s", err) 170 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 171 + return 172 + } 173 + 174 + s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 175 + return 176 + case http.MethodDelete: 177 + did := s.Auth.GetDid(r) 178 + emailAddr := r.FormValue("email") 179 + emailAddr = strings.TrimSpace(emailAddr) 180 + 181 + // Begin transaction 182 + tx, err := s.Db.Begin() 183 + if err != nil { 184 + log.Printf("failed to start transaction: %s", err) 185 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 186 + return 187 + } 188 + defer tx.Rollback() 189 + 190 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 191 + log.Printf("deleting email: %s", err) 192 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 193 + return 194 + } 195 + 196 + // Commit transaction 197 + if err := tx.Commit(); err != nil { 198 + log.Printf("failed to commit transaction: %s", err) 199 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 200 + return 201 + } 202 + 203 + s.Pages.HxLocation(w, "/settings") 204 + return 205 + } 206 + } 207 + 208 + func (s *Settings) verifyUrl(did string, email string, code string) string { 209 + var appUrl string 210 + if s.Config.Dev { 211 + appUrl = "http://" + s.Config.ListenAddr 212 + } else { 213 + appUrl = "https://tangled.sh" 214 + } 215 + 216 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 217 + } 218 + 219 + func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) { 220 + q := r.URL.Query() 221 + 222 + // Get the parameters directly from the query 223 + emailAddr := q.Get("email") 224 + did := q.Get("did") 225 + code := q.Get("code") 226 + 227 + valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 228 + if err != nil { 229 + log.Printf("checking email verification: %s", err) 230 + s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 231 + return 232 + } 233 + 234 + if !valid { 235 + s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 236 + return 237 + } 238 + 239 + // Mark email as verified in the database 240 + if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil { 241 + log.Printf("marking email as verified: %s", err) 242 + s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 243 + return 244 + } 245 + 246 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 247 + } 248 + 249 + func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { 250 + if r.Method != http.MethodPost { 251 + s.Pages.Notice(w, "settings-emails-error", "Invalid request method.") 252 + return 253 + } 254 + 255 + did := s.Auth.GetDid(r) 256 + emAddr := r.FormValue("email") 257 + emAddr = strings.TrimSpace(emAddr) 258 + 259 + if !email.IsValidEmail(emAddr) { 260 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 261 + return 262 + } 263 + 264 + // Check if email exists and is unverified 265 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 266 + if err != nil { 267 + if errors.Is(err, sql.ErrNoRows) { 268 + s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 269 + } else { 270 + log.Printf("checking for existing email: %s", err) 271 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 + } 273 + return 274 + } 275 + 276 + if existingEmail.Verified { 277 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 278 + return 279 + } 280 + 281 + // Check if last verification email was sent less than 10 minutes ago 282 + if existingEmail.LastSent != nil { 283 + timeSinceLastSent := time.Since(*existingEmail.LastSent) 284 + if timeSinceLastSent < 10*time.Minute { 285 + waitTime := 10*time.Minute - timeSinceLastSent 286 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 287 + return 288 + } 289 + } 290 + 291 + // Generate new verification code 292 + code := uuid.New().String() 293 + 294 + // Begin transaction 295 + tx, err := s.Db.Begin() 296 + if err != nil { 297 + log.Printf("failed to start transaction: %s", err) 298 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 299 + return 300 + } 301 + defer tx.Rollback() 302 + 303 + // Update the verification code and last sent time 304 + if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 305 + log.Printf("updating email verification: %s", err) 306 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 307 + return 308 + } 309 + 310 + // Send verification email 311 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 312 + return 313 + } 314 + 315 + // Commit transaction 316 + if err := tx.Commit(); err != nil { 317 + log.Printf("failed to commit transaction: %s", err) 318 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 319 + return 320 + } 321 + 322 + s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 323 + } 324 + 325 + func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) { 326 + did := s.Auth.GetDid(r) 327 + emailAddr := r.FormValue("email") 328 + emailAddr = strings.TrimSpace(emailAddr) 329 + 330 + if emailAddr == "" { 331 + s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 332 + return 333 + } 334 + 335 + if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil { 336 + log.Printf("setting primary email: %s", err) 337 + s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 338 + return 339 + } 340 + 341 + s.Pages.HxLocation(w, "/settings") 342 + } 343 + 344 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 345 + switch r.Method { 346 + case http.MethodGet: 347 + s.Pages.Notice(w, "settings-keys", "Unimplemented.") 348 + log.Println("unimplemented") 349 + return 350 + case http.MethodPut: 351 + did := s.Auth.GetDid(r) 352 + key := r.FormValue("key") 353 + key = strings.TrimSpace(key) 354 + name := r.FormValue("name") 355 + client, _ := s.Auth.AuthorizedClient(r) 356 + 357 + _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 358 + if err != nil { 359 + log.Printf("parsing public key: %s", err) 360 + s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 361 + return 362 + } 363 + 364 + rkey := appview.TID() 365 + 366 + tx, err := s.Db.Begin() 367 + if err != nil { 368 + log.Printf("failed to start tx; adding public key: %s", err) 369 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 + return 371 + } 372 + defer tx.Rollback() 373 + 374 + if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 375 + log.Printf("adding public key: %s", err) 376 + s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 377 + return 378 + } 379 + 380 + // store in pds too 381 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 382 + Collection: tangled.PublicKeyNSID, 383 + Repo: did, 384 + Rkey: rkey, 385 + Record: &lexutil.LexiconTypeDecoder{ 386 + Val: &tangled.PublicKey{ 387 + Created: time.Now().Format(time.RFC3339), 388 + Key: key, 389 + Name: name, 390 + }}, 391 + }) 392 + // invalid record 393 + if err != nil { 394 + log.Printf("failed to create record: %s", err) 395 + s.Pages.Notice(w, "settings-keys", "Failed to create record.") 396 + return 397 + } 398 + 399 + log.Println("created atproto record: ", resp.Uri) 400 + 401 + err = tx.Commit() 402 + if err != nil { 403 + log.Printf("failed to commit tx; adding public key: %s", err) 404 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 405 + return 406 + } 407 + 408 + s.Pages.HxLocation(w, "/settings") 409 + return 410 + 411 + case http.MethodDelete: 412 + did := s.Auth.GetDid(r) 413 + q := r.URL.Query() 414 + 415 + name := q.Get("name") 416 + rkey := q.Get("rkey") 417 + key := q.Get("key") 418 + 419 + log.Println(name) 420 + log.Println(rkey) 421 + log.Println(key) 422 + 423 + client, _ := s.Auth.AuthorizedClient(r) 424 + 425 + if err := db.RemovePublicKey(s.Db, did, name, key); err != nil { 426 + log.Printf("removing public key: %s", err) 427 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 428 + return 429 + } 430 + 431 + if rkey != "" { 432 + // remove from pds too 433 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 434 + Collection: tangled.PublicKeyNSID, 435 + Repo: did, 436 + Rkey: rkey, 437 + }) 438 + 439 + // invalid record 440 + if err != nil { 441 + log.Printf("failed to delete record from PDS: %s", err) 442 + s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 443 + return 444 + } 445 + } 446 + log.Println("deleted successfully") 447 + 448 + s.Pages.HxLocation(w, "/settings") 449 + return 450 + } 451 + }
+2 -1
appview/state/follow.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 tangled "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 ) ··· 36 switch r.Method { 37 case http.MethodPost: 38 createdAt := time.Now().Format(time.RFC3339) 39 - rkey := s.TID() 40 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 41 Collection: tangled.GraphFollowNSID, 42 Repo: currentUser.Did,
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 tangled "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/appview" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) ··· 37 switch r.Method { 38 case http.MethodPost: 39 createdAt := time.Now().Format(time.RFC3339) 40 + rkey := appview.TID() 41 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 42 Collection: tangled.GraphFollowNSID, 43 Repo: currentUser.Did,
+1 -1
appview/state/jetstream.go
··· 20 defer func() { 21 eventTime := e.TimeUS 22 lastTimeUs := eventTime + 1 23 - if err := d.UpdateLastTimeUs(lastTimeUs); err != nil { 24 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 25 } 26 }()
··· 20 defer func() { 21 eventTime := e.TimeUS 22 lastTimeUs := eventTime + 1 23 + if err := d.SaveLastTimeUs(lastTimeUs); err != nil { 24 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 25 } 26 }()
+16 -93
appview/state/middleware.go
··· 8 "strings" 9 "time" 10 11 - comatproto "github.com/bluesky-social/indigo/api/atproto" 12 "github.com/bluesky-social/indigo/atproto/identity" 13 - "github.com/bluesky-social/indigo/xrpc" 14 "github.com/go-chi/chi/v5" 15 - "tangled.sh/tangled.sh/core/appview" 16 - "tangled.sh/tangled.sh/core/appview/auth" 17 "tangled.sh/tangled.sh/core/appview/db" 18 ) 19 20 - type Middleware func(http.Handler) http.Handler 21 - 22 - func AuthMiddleware(s *State) Middleware { 23 - return func(next http.Handler) http.Handler { 24 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 26 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 27 - } 28 - if r.Header.Get("HX-Request") == "true" { 29 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 30 - w.Header().Set("HX-Redirect", "/login") 31 - w.WriteHeader(http.StatusOK) 32 - } 33 - } 34 - 35 - session, err := s.auth.GetSession(r) 36 - if session.IsNew || err != nil { 37 - log.Printf("not logged in, redirecting") 38 - redirectFunc(w, r) 39 - return 40 - } 41 - 42 - authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 43 - if !ok || !authorized { 44 - log.Printf("not logged in, redirecting") 45 - redirectFunc(w, r) 46 - return 47 - } 48 - 49 - // refresh if nearing expiry 50 - // TODO: dedup with /login 51 - expiryStr := session.Values[appview.SessionExpiry].(string) 52 - expiry, err := time.Parse(time.RFC3339, expiryStr) 53 - if err != nil { 54 - log.Println("invalid expiry time", err) 55 - redirectFunc(w, r) 56 - return 57 - } 58 - pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 59 - did, ok2 := session.Values[appview.SessionDid].(string) 60 - refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 61 - 62 - if !ok1 || !ok2 || !ok3 { 63 - log.Println("invalid expiry time", err) 64 - redirectFunc(w, r) 65 - return 66 - } 67 - 68 - if time.Now().After(expiry) { 69 - log.Println("token expired, refreshing ...") 70 - 71 - client := xrpc.Client{ 72 - Host: pdsUrl, 73 - Auth: &xrpc.AuthInfo{ 74 - Did: did, 75 - AccessJwt: refreshJwt, 76 - RefreshJwt: refreshJwt, 77 - }, 78 - } 79 - atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 80 - if err != nil { 81 - log.Println("failed to refresh session", err) 82 - redirectFunc(w, r) 83 - return 84 - } 85 - 86 - sessionish := auth.RefreshSessionWrapper{atSession} 87 - 88 - err = s.auth.StoreSession(r, w, &sessionish, pdsUrl) 89 - if err != nil { 90 - log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 91 - return 92 - } 93 - 94 - log.Println("successfully refreshed token") 95 - } 96 - 97 - next.ServeHTTP(w, r) 98 - }) 99 - } 100 - } 101 - 102 - func knotRoleMiddleware(s *State, group string) Middleware { 103 return func(next http.Handler) http.Handler { 104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 // requires auth also ··· 129 } 130 } 131 132 - func KnotOwner(s *State) Middleware { 133 return knotRoleMiddleware(s, "server:owner") 134 } 135 136 - func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware { 137 return func(next http.Handler) http.Handler { 138 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 // requires auth also ··· 150 return 151 } 152 153 - ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm) 154 if err != nil || !ok { 155 // we need a logged in user 156 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) ··· 173 }) 174 } 175 176 - func ResolveIdent(s *State) Middleware { 177 return func(next http.Handler) http.Handler { 178 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 179 didOrHandle := chi.URLParam(req, "user") 180 181 id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 182 if err != nil { ··· 193 } 194 } 195 196 - func ResolveRepo(s *State) Middleware { 197 return func(next http.Handler) http.Handler { 198 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 199 repoName := chi.URLParam(req, "repo") ··· 222 } 223 224 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 225 - func ResolvePull(s *State) Middleware { 226 return func(next http.Handler) http.Handler { 227 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 228 f, err := fullyResolvedRepo(r)
··· 8 "strings" 9 "time" 10 11 + "slices" 12 + 13 "github.com/bluesky-social/indigo/atproto/identity" 14 "github.com/go-chi/chi/v5" 15 "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/middleware" 17 ) 18 19 + func knotRoleMiddleware(s *State, group string) middleware.Middleware { 20 return func(next http.Handler) http.Handler { 21 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 // requires auth also ··· 46 } 47 } 48 49 + func KnotOwner(s *State) middleware.Middleware { 50 return knotRoleMiddleware(s, "server:owner") 51 } 52 53 + func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { 54 return func(next http.Handler) http.Handler { 55 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 // requires auth also ··· 67 return 68 } 69 70 + ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 71 if err != nil || !ok { 72 // we need a logged in user 73 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) ··· 90 }) 91 } 92 93 + func ResolveIdent(s *State) middleware.Middleware { 94 + excluded := []string{"favicon.ico"} 95 + 96 return func(next http.Handler) http.Handler { 97 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 98 didOrHandle := chi.URLParam(req, "user") 99 + if slices.Contains(excluded, didOrHandle) { 100 + next.ServeHTTP(w, req) 101 + return 102 + } 103 104 id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 105 if err != nil { ··· 116 } 117 } 118 119 + func ResolveRepo(s *State) middleware.Middleware { 120 return func(next http.Handler) http.Handler { 121 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 122 repoName := chi.URLParam(req, "repo") ··· 145 } 146 147 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 148 + func ResolvePull(s *State) middleware.Middleware { 149 return func(next http.Handler) http.Handler { 150 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 f, err := fullyResolvedRepo(r)
+102
appview/state/profile.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/go-chi/chi/v5" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/pages" 12 + ) 13 + 14 + func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 15 + didOrHandle := chi.URLParam(r, "user") 16 + if didOrHandle == "" { 17 + http.Error(w, "Bad request", http.StatusBadRequest) 18 + return 19 + } 20 + 21 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 22 + if !ok { 23 + s.pages.Error404(w) 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 + }
+1011 -146
appview/state/pull.go
··· 1 package state 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "log" 8 "net/http" 9 "strconv" 10 - "strings" 11 "time" 12 13 - "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/db" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/types" 18 19 comatproto "github.com/bluesky-social/indigo/api/atproto" 20 lexutil "github.com/bluesky-social/indigo/lex/util" 21 ) 22 23 // htmx fragment ··· 50 } 51 52 mergeCheckResponse := s.mergeCheck(f, pull) 53 54 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 55 - LoggedInUser: user, 56 - RepoInfo: f.RepoInfo(s, user), 57 - Pull: pull, 58 - RoundNumber: roundNumber, 59 - MergeCheck: mergeCheckResponse, 60 }) 61 return 62 } ··· 105 } 106 107 mergeCheckResponse := s.mergeCheck(f, pull) 108 109 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 110 - LoggedInUser: user, 111 - RepoInfo: f.RepoInfo(s, user), 112 - DidHandleMap: didHandleMap, 113 - Pull: *pull, 114 - MergeCheck: mergeCheckResponse, 115 }) 116 } 117 ··· 175 return mergeCheckResponse 176 } 177 178 func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 179 user := s.auth.GetUser(r) 180 f, err := fullyResolvedRepo(r) ··· 209 } 210 } 211 212 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 213 LoggedInUser: user, 214 DidHandleMap: didHandleMap, ··· 216 Pull: pull, 217 Round: roundIdInt, 218 Submission: pull.Submissions[roundIdInt], 219 - Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 220 }) 221 222 } 223 224 func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 225 user := s.auth.GetUser(r) 226 params := r.URL.Query() ··· 244 log.Println("failed to get pulls", err) 245 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 246 return 247 } 248 249 identsToResolve := make([]string, len(pulls)) ··· 333 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 334 Collection: tangled.RepoPullCommentNSID, 335 Repo: user.Did, 336 - Rkey: s.TID(), 337 Record: &lexutil.LexiconTypeDecoder{ 338 Val: &tangled.RepoPullComment{ 339 Repo: &atUri, ··· 344 }, 345 }, 346 }) 347 - log.Println(atResp.Uri) 348 if err != nil { 349 log.Println("failed to create pull comment", err) 350 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 423 title := r.FormValue("title") 424 body := r.FormValue("body") 425 targetBranch := r.FormValue("targetBranch") 426 patch := r.FormValue("patch") 427 428 - if title == "" || body == "" || patch == "" || targetBranch == "" { 429 - s.pages.Notice(w, "pull", "Title, body and patch diff are required.") 430 return 431 } 432 433 - // Validate patch format 434 - if !isPatchValid(patch) { 435 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 436 - return 437 } 438 439 - tx, err := s.db.BeginTx(r.Context(), nil) 440 - if err != nil { 441 - log.Println("failed to start tx") 442 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 443 return 444 } 445 - defer tx.Rollback() 446 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.") 465 return 466 } 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.") 472 return 473 } 474 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 - }, 487 - }, 488 - }) 489 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 } 496 497 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 498 return 499 } 500 } 501 502 func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { ··· 522 }) 523 return 524 case http.MethodPost: 525 - patch := r.FormValue("patch") 526 - 527 - if patch == "" { 528 - s.pages.Notice(w, "resubmit-error", "Patch is empty.") 529 return 530 } 531 532 - if patch == pull.LatestPatch() { 533 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 534 - return 535 - } 536 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 - } 542 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() 550 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) 558 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 - } 565 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 - }, 579 }, 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 - } 586 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 - } 592 593 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 594 return 595 } 596 } 597 598 func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { ··· 617 return 618 } 619 620 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 621 if err != nil { 622 log.Printf("failed to create signed client for %s: %s", f.Knot, err) ··· 625 } 626 627 // Merge the pull request 628 - resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "") 629 if err != nil { 630 log.Printf("failed to merge pull request: %s", err) 631 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 754 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 755 return 756 } 757 - 758 - // Very basic validation to check if it looks like a diff/patch 759 - // A valid patch usually starts with diff or --- lines 760 - func isPatchValid(patch string) bool { 761 - // Basic validation to check if it looks like a diff/patch 762 - // A valid patch usually starts with diff or --- lines 763 - if len(patch) == 0 { 764 - return false 765 - } 766 - 767 - lines := strings.Split(patch, "\n") 768 - if len(lines) < 2 { 769 - return false 770 - } 771 - 772 - // Check for common patch format markers 773 - firstLine := strings.TrimSpace(lines[0]) 774 - return strings.HasPrefix(firstLine, "diff ") || 775 - strings.HasPrefix(firstLine, "--- ") || 776 - strings.HasPrefix(firstLine, "Index: ") || 777 - strings.HasPrefix(firstLine, "+++ ") || 778 - strings.HasPrefix(firstLine, "@@ ") 779 - }
··· 1 package state 2 3 import ( 4 + "database/sql" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 + "net/url" 12 "strconv" 13 "time" 14 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview" 17 + "tangled.sh/tangled.sh/core/appview/auth" 18 "tangled.sh/tangled.sh/core/appview/db" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/patchutil" 21 "tangled.sh/tangled.sh/core/types" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + "github.com/bluesky-social/indigo/atproto/syntax" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 27 ) 28 29 // htmx fragment ··· 56 } 57 58 mergeCheckResponse := s.mergeCheck(f, pull) 59 + resubmitResult := pages.Unknown 60 + if user.Did == pull.OwnerDid { 61 + resubmitResult = s.resubmitCheck(f, pull) 62 + } 63 64 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 65 + LoggedInUser: user, 66 + RepoInfo: f.RepoInfo(s, user), 67 + Pull: pull, 68 + RoundNumber: roundNumber, 69 + MergeCheck: mergeCheckResponse, 70 + ResubmitCheck: resubmitResult, 71 }) 72 return 73 } ··· 116 } 117 118 mergeCheckResponse := s.mergeCheck(f, pull) 119 + resubmitResult := pages.Unknown 120 + if user != nil && user.Did == pull.OwnerDid { 121 + resubmitResult = s.resubmitCheck(f, pull) 122 + } 123 124 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(s, user), 127 + DidHandleMap: didHandleMap, 128 + Pull: pull, 129 + MergeCheck: mergeCheckResponse, 130 + ResubmitCheck: resubmitResult, 131 }) 132 } 133 ··· 191 return mergeCheckResponse 192 } 193 194 + func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { 195 + if pull.State == db.PullMerged || pull.PullSource == nil { 196 + return pages.Unknown 197 + } 198 + 199 + var knot, ownerDid, repoName string 200 + 201 + if pull.PullSource.RepoAt != nil { 202 + // fork-based pulls 203 + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 204 + if err != nil { 205 + log.Println("failed to get source repo", err) 206 + return pages.Unknown 207 + } 208 + 209 + knot = sourceRepo.Knot 210 + ownerDid = sourceRepo.Did 211 + repoName = sourceRepo.Name 212 + } else { 213 + // pulls within the same repo 214 + knot = f.Knot 215 + ownerDid = f.OwnerDid() 216 + repoName = f.RepoName 217 + } 218 + 219 + us, err := NewUnsignedClient(knot, s.config.Dev) 220 + if err != nil { 221 + log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 222 + return pages.Unknown 223 + } 224 + 225 + resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 226 + if err != nil { 227 + log.Println("failed to reach knotserver", err) 228 + return pages.Unknown 229 + } 230 + 231 + body, err := io.ReadAll(resp.Body) 232 + if err != nil { 233 + log.Printf("error reading response body: %v", err) 234 + return pages.Unknown 235 + } 236 + defer resp.Body.Close() 237 + 238 + var result types.RepoBranchResponse 239 + if err := json.Unmarshal(body, &result); err != nil { 240 + log.Println("failed to parse response:", err) 241 + return pages.Unknown 242 + } 243 + 244 + latestSubmission := pull.Submissions[pull.LastRoundNumber()] 245 + if latestSubmission.SourceRev != result.Branch.Hash { 246 + fmt.Println(latestSubmission.SourceRev, result.Branch.Hash) 247 + return pages.ShouldResubmit 248 + } 249 + 250 + return pages.ShouldNotResubmit 251 + } 252 + 253 func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 254 user := s.auth.GetUser(r) 255 f, err := fullyResolvedRepo(r) ··· 284 } 285 } 286 287 + diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) 288 + 289 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 290 LoggedInUser: user, 291 DidHandleMap: didHandleMap, ··· 293 Pull: pull, 294 Round: roundIdInt, 295 Submission: pull.Submissions[roundIdInt], 296 + Diff: &diff, 297 }) 298 299 } 300 301 + func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 302 + user := s.auth.GetUser(r) 303 + 304 + f, err := fullyResolvedRepo(r) 305 + if err != nil { 306 + log.Println("failed to get repo and knot", err) 307 + return 308 + } 309 + 310 + pull, ok := r.Context().Value("pull").(*db.Pull) 311 + if !ok { 312 + log.Println("failed to get pull") 313 + s.pages.Notice(w, "pull-error", "Failed to get pull.") 314 + return 315 + } 316 + 317 + roundId := chi.URLParam(r, "round") 318 + roundIdInt, err := strconv.Atoi(roundId) 319 + if err != nil || roundIdInt >= len(pull.Submissions) { 320 + http.Error(w, "bad round id", http.StatusBadRequest) 321 + log.Println("failed to parse round id", err) 322 + return 323 + } 324 + 325 + if roundIdInt == 0 { 326 + http.Error(w, "bad round id", http.StatusBadRequest) 327 + log.Println("cannot interdiff initial submission") 328 + return 329 + } 330 + 331 + identsToResolve := []string{pull.OwnerDid} 332 + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 333 + didHandleMap := make(map[string]string) 334 + for _, identity := range resolvedIds { 335 + if !identity.Handle.IsInvalidHandle() { 336 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 337 + } else { 338 + didHandleMap[identity.DID.String()] = identity.DID.String() 339 + } 340 + } 341 + 342 + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 343 + if err != nil { 344 + log.Println("failed to interdiff; current patch malformed") 345 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 346 + return 347 + } 348 + 349 + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 350 + if err != nil { 351 + log.Println("failed to interdiff; previous patch malformed") 352 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 353 + return 354 + } 355 + 356 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 357 + 358 + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 359 + LoggedInUser: s.auth.GetUser(r), 360 + RepoInfo: f.RepoInfo(s, user), 361 + Pull: pull, 362 + Round: roundIdInt, 363 + DidHandleMap: didHandleMap, 364 + Interdiff: interdiff, 365 + }) 366 + return 367 + } 368 + 369 + func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 370 + pull, ok := r.Context().Value("pull").(*db.Pull) 371 + if !ok { 372 + log.Println("failed to get pull") 373 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 374 + return 375 + } 376 + 377 + roundId := chi.URLParam(r, "round") 378 + roundIdInt, err := strconv.Atoi(roundId) 379 + if err != nil || roundIdInt >= len(pull.Submissions) { 380 + http.Error(w, "bad round id", http.StatusBadRequest) 381 + log.Println("failed to parse round id", err) 382 + return 383 + } 384 + 385 + identsToResolve := []string{pull.OwnerDid} 386 + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 387 + didHandleMap := make(map[string]string) 388 + for _, identity := range resolvedIds { 389 + if !identity.Handle.IsInvalidHandle() { 390 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 391 + } else { 392 + didHandleMap[identity.DID.String()] = identity.DID.String() 393 + } 394 + } 395 + 396 + w.Header().Set("Content-Type", "text/plain") 397 + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 398 + } 399 + 400 func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 401 user := s.auth.GetUser(r) 402 params := r.URL.Query() ··· 420 log.Println("failed to get pulls", err) 421 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 422 return 423 + } 424 + 425 + for _, p := range pulls { 426 + var pullSourceRepo *db.Repo 427 + if p.PullSource != nil { 428 + if p.PullSource.RepoAt != nil { 429 + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 430 + if err != nil { 431 + log.Printf("failed to get repo by at uri: %v", err) 432 + continue 433 + } else { 434 + p.PullSource.Repo = pullSourceRepo 435 + } 436 + } 437 + } 438 } 439 440 identsToResolve := make([]string, len(pulls)) ··· 524 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 525 Collection: tangled.RepoPullCommentNSID, 526 Repo: user.Did, 527 + Rkey: appview.TID(), 528 Record: &lexutil.LexiconTypeDecoder{ 529 Val: &tangled.RepoPullComment{ 530 Repo: &atUri, ··· 535 }, 536 }, 537 }) 538 if err != nil { 539 log.Println("failed to create pull comment", err) 540 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 613 title := r.FormValue("title") 614 body := r.FormValue("body") 615 targetBranch := r.FormValue("targetBranch") 616 + fromFork := r.FormValue("fork") 617 + sourceBranch := r.FormValue("sourceBranch") 618 patch := r.FormValue("patch") 619 620 + if targetBranch == "" { 621 + s.pages.Notice(w, "pull", "Target branch is required.") 622 return 623 } 624 625 + // Determine PR type based on input parameters 626 + isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 627 + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 628 + isForkBased := fromFork != "" && sourceBranch != "" 629 + isPatchBased := patch != "" && !isBranchBased && !isForkBased 630 + 631 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 632 + if title == "" { 633 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 634 + return 635 + } 636 } 637 638 + // Validate we have at least one valid PR creation method 639 + if !isBranchBased && !isPatchBased && !isForkBased { 640 + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 641 return 642 } 643 644 + // Can't mix branch-based and patch-based approaches 645 + if isBranchBased && patch != "" { 646 + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 647 + return 648 } 649 + 650 + us, err := NewUnsignedClient(f.Knot, s.config.Dev) 651 if err != nil { 652 + log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 653 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 654 return 655 } 656 + 657 + caps, err := us.Capabilities() 658 if err != nil { 659 + log.Println("error fetching knot caps", f.Knot, err) 660 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 661 + return 662 + } 663 + 664 + if !caps.PullRequests.FormatPatch { 665 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 666 return 667 } 668 669 + // Handle the PR creation based on the type 670 + if isBranchBased { 671 + if !caps.PullRequests.BranchSubmissions { 672 + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 673 + return 674 + } 675 + s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 676 + } else if isForkBased { 677 + if !caps.PullRequests.ForkSubmissions { 678 + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 679 + return 680 + } 681 + s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 682 + } else if isPatchBased { 683 + if !caps.PullRequests.PatchSubmissions { 684 + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 685 + return 686 + } 687 + s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 688 + } 689 + return 690 + } 691 + } 692 + 693 + func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 694 + pullSource := &db.PullSource{ 695 + Branch: sourceBranch, 696 + } 697 + recordPullSource := &tangled.RepoPull_Source{ 698 + Branch: sourceBranch, 699 + } 700 + 701 + // Generate a patch using /compare 702 + ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 703 + if err != nil { 704 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 705 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 706 + return 707 + } 708 + 709 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 710 + if err != nil { 711 + log.Println("failed to compare", err) 712 + s.pages.Notice(w, "pull", err.Error()) 713 + return 714 + } 715 + 716 + sourceRev := comparison.Rev2 717 + patch := comparison.Patch 718 + 719 + if !patchutil.IsPatchValid(patch) { 720 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 721 + return 722 + } 723 + 724 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 725 + } 726 + 727 + func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 728 + if !patchutil.IsPatchValid(patch) { 729 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 730 + return 731 + } 732 + 733 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 734 + } 735 + 736 + func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 737 + fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 738 + if errors.Is(err, sql.ErrNoRows) { 739 + s.pages.Notice(w, "pull", "No such fork.") 740 + return 741 + } else if err != nil { 742 + log.Println("failed to fetch fork:", err) 743 + s.pages.Notice(w, "pull", "Failed to fetch fork.") 744 + return 745 + } 746 + 747 + secret, err := db.GetRegistrationKey(s.db, fork.Knot) 748 + if err != nil { 749 + log.Println("failed to fetch registration key:", err) 750 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 751 + return 752 + } 753 + 754 + sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 755 + if err != nil { 756 + log.Println("failed to create signed client:", err) 757 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 758 + return 759 + } 760 + 761 + us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 762 + if err != nil { 763 + log.Println("failed to create unsigned client:", err) 764 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 765 + return 766 + } 767 + 768 + resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 769 + if err != nil { 770 + log.Println("failed to create hidden ref:", err, resp.StatusCode) 771 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 772 + return 773 + } 774 + 775 + switch resp.StatusCode { 776 + case 404: 777 + case 400: 778 + s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 779 + return 780 + } 781 + 782 + hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 783 + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 784 + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 785 + // hiddenRef: hidden/feature-1/main (on repo-fork) 786 + // targetBranch: main (on repo-1) 787 + // sourceBranch: feature-1 (on repo-fork) 788 + comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 789 + if err != nil { 790 + log.Println("failed to compare across branches", err) 791 + s.pages.Notice(w, "pull", err.Error()) 792 + return 793 + } 794 + 795 + sourceRev := comparison.Rev2 796 + patch := comparison.Patch 797 + 798 + if !patchutil.IsPatchValid(patch) { 799 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 800 + return 801 + } 802 + 803 + forkAtUri, err := syntax.ParseATURI(fork.AtUri) 804 + if err != nil { 805 + log.Println("failed to parse fork AT URI", err) 806 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 807 + return 808 + } 809 + 810 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 811 + Branch: sourceBranch, 812 + RepoAt: &forkAtUri, 813 + }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 814 + } 815 + 816 + func (s *State) createPullRequest( 817 + w http.ResponseWriter, 818 + r *http.Request, 819 + f *FullyResolvedRepo, 820 + user *auth.User, 821 + title, body, targetBranch string, 822 + patch string, 823 + sourceRev string, 824 + pullSource *db.PullSource, 825 + recordPullSource *tangled.RepoPull_Source, 826 + ) { 827 + tx, err := s.db.BeginTx(r.Context(), nil) 828 + if err != nil { 829 + log.Println("failed to start tx") 830 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 831 + return 832 + } 833 + defer tx.Rollback() 834 835 + // We've already checked earlier if it's diff-based and title is empty, 836 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 837 + if title == "" { 838 + formatPatches, err := patchutil.ExtractPatches(patch) 839 if err != nil { 840 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 841 + return 842 + } 843 + if len(formatPatches) == 0 { 844 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 845 return 846 } 847 848 + title = formatPatches[0].Title 849 + body = formatPatches[0].Body 850 + } 851 + 852 + rkey := appview.TID() 853 + initialSubmission := db.PullSubmission{ 854 + Patch: patch, 855 + SourceRev: sourceRev, 856 + } 857 + err = db.NewPull(tx, &db.Pull{ 858 + Title: title, 859 + Body: body, 860 + TargetBranch: targetBranch, 861 + OwnerDid: user.Did, 862 + RepoAt: f.RepoAt, 863 + Rkey: rkey, 864 + Submissions: []*db.PullSubmission{ 865 + &initialSubmission, 866 + }, 867 + PullSource: pullSource, 868 + }) 869 + if err != nil { 870 + log.Println("failed to create pull request", err) 871 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 872 + return 873 + } 874 + client, _ := s.auth.AuthorizedClient(r) 875 + pullId, err := db.NextPullId(s.db, f.RepoAt) 876 + if err != nil { 877 + log.Println("failed to get pull id", err) 878 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 879 + return 880 + } 881 + 882 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 883 + Collection: tangled.RepoPullNSID, 884 + Repo: user.Did, 885 + Rkey: rkey, 886 + Record: &lexutil.LexiconTypeDecoder{ 887 + Val: &tangled.RepoPull{ 888 + Title: title, 889 + PullId: int64(pullId), 890 + TargetRepo: string(f.RepoAt), 891 + TargetBranch: targetBranch, 892 + Patch: patch, 893 + Source: recordPullSource, 894 + }, 895 + }, 896 + }) 897 + 898 + err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 899 + if err != nil { 900 + log.Println("failed to get pull id", err) 901 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 902 + return 903 + } 904 + 905 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 906 + } 907 + 908 + func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { 909 + _, err := fullyResolvedRepo(r) 910 + if err != nil { 911 + log.Println("failed to get repo and knot", err) 912 + return 913 + } 914 + 915 + patch := r.FormValue("patch") 916 + if patch == "" { 917 + s.pages.Notice(w, "patch-error", "Patch is required.") 918 + return 919 + } 920 + 921 + if patch == "" || !patchutil.IsPatchValid(patch) { 922 + s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 923 + return 924 + } 925 + 926 + if patchutil.IsFormatPatch(patch) { 927 + s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") 928 + } else { 929 + s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 930 + } 931 + } 932 + 933 + func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 934 + user := s.auth.GetUser(r) 935 + f, err := fullyResolvedRepo(r) 936 + if err != nil { 937 + log.Println("failed to get repo and knot", err) 938 + return 939 + } 940 + 941 + s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 942 + RepoInfo: f.RepoInfo(s, user), 943 + }) 944 + } 945 + 946 + func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 947 + user := s.auth.GetUser(r) 948 + f, err := fullyResolvedRepo(r) 949 + if err != nil { 950 + log.Println("failed to get repo and knot", err) 951 + return 952 + } 953 + 954 + us, err := NewUnsignedClient(f.Knot, s.config.Dev) 955 + if err != nil { 956 + log.Printf("failed to create unsigned client for %s", f.Knot) 957 + s.pages.Error503(w) 958 + return 959 + } 960 + 961 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 962 + if err != nil { 963 + log.Println("failed to reach knotserver", err) 964 + return 965 + } 966 + 967 + body, err := io.ReadAll(resp.Body) 968 + if err != nil { 969 + log.Printf("Error reading response body: %v", err) 970 + return 971 + } 972 + 973 + var result types.RepoBranchesResponse 974 + err = json.Unmarshal(body, &result) 975 + if err != nil { 976 + log.Println("failed to parse response:", err) 977 + return 978 + } 979 + 980 + s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 981 + RepoInfo: f.RepoInfo(s, user), 982 + Branches: result.Branches, 983 + }) 984 + } 985 + 986 + func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 987 + user := s.auth.GetUser(r) 988 + f, err := fullyResolvedRepo(r) 989 + if err != nil { 990 + log.Println("failed to get repo and knot", err) 991 + return 992 + } 993 + 994 + forks, err := db.GetForksByDid(s.db, user.Did) 995 + if err != nil { 996 + log.Println("failed to get forks", err) 997 + return 998 + } 999 + 1000 + s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1001 + RepoInfo: f.RepoInfo(s, user), 1002 + Forks: forks, 1003 + }) 1004 + } 1005 + 1006 + func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1007 + user := s.auth.GetUser(r) 1008 + 1009 + f, err := fullyResolvedRepo(r) 1010 + if err != nil { 1011 + log.Println("failed to get repo and knot", err) 1012 + return 1013 + } 1014 + 1015 + forkVal := r.URL.Query().Get("fork") 1016 + 1017 + // fork repo 1018 + repo, err := db.GetRepo(s.db, user.Did, forkVal) 1019 + if err != nil { 1020 + log.Println("failed to get repo", user.Did, forkVal) 1021 + return 1022 + } 1023 + 1024 + sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 1025 + if err != nil { 1026 + log.Printf("failed to create unsigned client for %s", repo.Knot) 1027 + s.pages.Error503(w) 1028 + return 1029 + } 1030 + 1031 + sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1032 + if err != nil { 1033 + log.Println("failed to reach knotserver for source branches", err) 1034 + return 1035 + } 1036 + 1037 + sourceBody, err := io.ReadAll(sourceResp.Body) 1038 + if err != nil { 1039 + log.Println("failed to read source response body", err) 1040 return 1041 } 1042 + defer sourceResp.Body.Close() 1043 + 1044 + var sourceResult types.RepoBranchesResponse 1045 + err = json.Unmarshal(sourceBody, &sourceResult) 1046 + if err != nil { 1047 + log.Println("failed to parse source branches response:", err) 1048 + return 1049 + } 1050 + 1051 + targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 1052 + if err != nil { 1053 + log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1054 + s.pages.Error503(w) 1055 + return 1056 + } 1057 + 1058 + targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1059 + if err != nil { 1060 + log.Println("failed to reach knotserver for target branches", err) 1061 + return 1062 + } 1063 + 1064 + targetBody, err := io.ReadAll(targetResp.Body) 1065 + if err != nil { 1066 + log.Println("failed to read target response body", err) 1067 + return 1068 + } 1069 + defer targetResp.Body.Close() 1070 + 1071 + var targetResult types.RepoBranchesResponse 1072 + err = json.Unmarshal(targetBody, &targetResult) 1073 + if err != nil { 1074 + log.Println("failed to parse target branches response:", err) 1075 + return 1076 + } 1077 + 1078 + s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1079 + RepoInfo: f.RepoInfo(s, user), 1080 + SourceBranches: sourceResult.Branches, 1081 + TargetBranches: targetResult.Branches, 1082 + }) 1083 } 1084 1085 func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { ··· 1105 }) 1106 return 1107 case http.MethodPost: 1108 + if pull.IsPatchBased() { 1109 + s.resubmitPatch(w, r) 1110 + return 1111 + } else if pull.IsBranchBased() { 1112 + s.resubmitBranch(w, r) 1113 + return 1114 + } else if pull.IsForkBased() { 1115 + s.resubmitFork(w, r) 1116 return 1117 } 1118 + } 1119 + } 1120 1121 + func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1122 + user := s.auth.GetUser(r) 1123 1124 + pull, ok := r.Context().Value("pull").(*db.Pull) 1125 + if !ok { 1126 + log.Println("failed to get pull") 1127 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1128 + return 1129 + } 1130 1131 + f, err := fullyResolvedRepo(r) 1132 + if err != nil { 1133 + log.Println("failed to get repo and knot", err) 1134 + return 1135 + } 1136 1137 + if user.Did != pull.OwnerDid { 1138 + log.Println("unauthorized user") 1139 + w.WriteHeader(http.StatusUnauthorized) 1140 + return 1141 + } 1142 1143 + patch := r.FormValue("patch") 1144 1145 + if err = validateResubmittedPatch(pull, patch); err != nil { 1146 + s.pages.Notice(w, "resubmit-error", err.Error()) 1147 + return 1148 + } 1149 + 1150 + tx, err := s.db.BeginTx(r.Context(), nil) 1151 + if err != nil { 1152 + log.Println("failed to start tx") 1153 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1154 + return 1155 + } 1156 + defer tx.Rollback() 1157 + 1158 + err = db.ResubmitPull(tx, pull, patch, "") 1159 + if err != nil { 1160 + log.Println("failed to resubmit pull request", err) 1161 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") 1162 + return 1163 + } 1164 + client, _ := s.auth.AuthorizedClient(r) 1165 + 1166 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1167 + if err != nil { 1168 + // failed to get record 1169 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1170 + return 1171 + } 1172 + 1173 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1174 + Collection: tangled.RepoPullNSID, 1175 + Repo: user.Did, 1176 + Rkey: pull.Rkey, 1177 + SwapRecord: ex.Cid, 1178 + Record: &lexutil.LexiconTypeDecoder{ 1179 + Val: &tangled.RepoPull{ 1180 + Title: pull.Title, 1181 + PullId: int64(pull.PullId), 1182 + TargetRepo: string(f.RepoAt), 1183 + TargetBranch: pull.TargetBranch, 1184 + Patch: patch, // new patch 1185 }, 1186 + }, 1187 + }) 1188 + if err != nil { 1189 + log.Println("failed to update record", err) 1190 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1191 + return 1192 + } 1193 1194 + if err = tx.Commit(); err != nil { 1195 + log.Println("failed to commit transaction", err) 1196 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1197 + return 1198 + } 1199 1200 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1201 + return 1202 + } 1203 + 1204 + func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1205 + user := s.auth.GetUser(r) 1206 + 1207 + pull, ok := r.Context().Value("pull").(*db.Pull) 1208 + if !ok { 1209 + log.Println("failed to get pull") 1210 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1211 return 1212 } 1213 + 1214 + f, err := fullyResolvedRepo(r) 1215 + if err != nil { 1216 + log.Println("failed to get repo and knot", err) 1217 + return 1218 + } 1219 + 1220 + if user.Did != pull.OwnerDid { 1221 + log.Println("unauthorized user") 1222 + w.WriteHeader(http.StatusUnauthorized) 1223 + return 1224 + } 1225 + 1226 + if !f.RepoInfo(s, user).Roles.IsPushAllowed() { 1227 + log.Println("unauthorized user") 1228 + w.WriteHeader(http.StatusUnauthorized) 1229 + return 1230 + } 1231 + 1232 + ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 1233 + if err != nil { 1234 + log.Printf("failed to create client for %s: %s", f.Knot, err) 1235 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1236 + return 1237 + } 1238 + 1239 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1240 + if err != nil { 1241 + log.Printf("compare request failed: %s", err) 1242 + s.pages.Notice(w, "resubmit-error", err.Error()) 1243 + return 1244 + } 1245 + 1246 + sourceRev := comparison.Rev2 1247 + patch := comparison.Patch 1248 + 1249 + if err = validateResubmittedPatch(pull, patch); err != nil { 1250 + s.pages.Notice(w, "resubmit-error", err.Error()) 1251 + return 1252 + } 1253 + 1254 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1255 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1256 + return 1257 + } 1258 + 1259 + tx, err := s.db.BeginTx(r.Context(), nil) 1260 + if err != nil { 1261 + log.Println("failed to start tx") 1262 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1263 + return 1264 + } 1265 + defer tx.Rollback() 1266 + 1267 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1268 + if err != nil { 1269 + log.Println("failed to create pull request", err) 1270 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1271 + return 1272 + } 1273 + client, _ := s.auth.AuthorizedClient(r) 1274 + 1275 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1276 + if err != nil { 1277 + // failed to get record 1278 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1279 + return 1280 + } 1281 + 1282 + recordPullSource := &tangled.RepoPull_Source{ 1283 + Branch: pull.PullSource.Branch, 1284 + } 1285 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1286 + Collection: tangled.RepoPullNSID, 1287 + Repo: user.Did, 1288 + Rkey: pull.Rkey, 1289 + SwapRecord: ex.Cid, 1290 + Record: &lexutil.LexiconTypeDecoder{ 1291 + Val: &tangled.RepoPull{ 1292 + Title: pull.Title, 1293 + PullId: int64(pull.PullId), 1294 + TargetRepo: string(f.RepoAt), 1295 + TargetBranch: pull.TargetBranch, 1296 + Patch: patch, // new patch 1297 + Source: recordPullSource, 1298 + }, 1299 + }, 1300 + }) 1301 + if err != nil { 1302 + log.Println("failed to update record", err) 1303 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1304 + return 1305 + } 1306 + 1307 + if err = tx.Commit(); err != nil { 1308 + log.Println("failed to commit transaction", err) 1309 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1310 + return 1311 + } 1312 + 1313 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1314 + return 1315 + } 1316 + 1317 + func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1318 + user := s.auth.GetUser(r) 1319 + 1320 + pull, ok := r.Context().Value("pull").(*db.Pull) 1321 + if !ok { 1322 + log.Println("failed to get pull") 1323 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1324 + return 1325 + } 1326 + 1327 + f, err := fullyResolvedRepo(r) 1328 + if err != nil { 1329 + log.Println("failed to get repo and knot", err) 1330 + return 1331 + } 1332 + 1333 + if user.Did != pull.OwnerDid { 1334 + log.Println("unauthorized user") 1335 + w.WriteHeader(http.StatusUnauthorized) 1336 + return 1337 + } 1338 + 1339 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1340 + if err != nil { 1341 + log.Println("failed to get source repo", err) 1342 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1343 + return 1344 + } 1345 + 1346 + // extract patch by performing compare 1347 + ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev) 1348 + if err != nil { 1349 + log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1350 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1351 + return 1352 + } 1353 + 1354 + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1355 + if err != nil { 1356 + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1357 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1358 + return 1359 + } 1360 + 1361 + // update the hidden tracking branch to latest 1362 + signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev) 1363 + if err != nil { 1364 + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1365 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1366 + return 1367 + } 1368 + 1369 + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1370 + if err != nil || resp.StatusCode != http.StatusNoContent { 1371 + log.Printf("failed to update tracking branch: %s", err) 1372 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1373 + return 1374 + } 1375 + 1376 + hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)) 1377 + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1378 + if err != nil { 1379 + log.Printf("failed to compare branches: %s", err) 1380 + s.pages.Notice(w, "resubmit-error", err.Error()) 1381 + return 1382 + } 1383 + 1384 + sourceRev := comparison.Rev2 1385 + patch := comparison.Patch 1386 + 1387 + if err = validateResubmittedPatch(pull, patch); err != nil { 1388 + s.pages.Notice(w, "resubmit-error", err.Error()) 1389 + return 1390 + } 1391 + 1392 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1393 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1394 + return 1395 + } 1396 + 1397 + tx, err := s.db.BeginTx(r.Context(), nil) 1398 + if err != nil { 1399 + log.Println("failed to start tx") 1400 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1401 + return 1402 + } 1403 + defer tx.Rollback() 1404 + 1405 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1406 + if err != nil { 1407 + log.Println("failed to create pull request", err) 1408 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1409 + return 1410 + } 1411 + client, _ := s.auth.AuthorizedClient(r) 1412 + 1413 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1414 + if err != nil { 1415 + // failed to get record 1416 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1417 + return 1418 + } 1419 + 1420 + repoAt := pull.PullSource.RepoAt.String() 1421 + recordPullSource := &tangled.RepoPull_Source{ 1422 + Branch: pull.PullSource.Branch, 1423 + Repo: &repoAt, 1424 + } 1425 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1426 + Collection: tangled.RepoPullNSID, 1427 + Repo: user.Did, 1428 + Rkey: pull.Rkey, 1429 + SwapRecord: ex.Cid, 1430 + Record: &lexutil.LexiconTypeDecoder{ 1431 + Val: &tangled.RepoPull{ 1432 + Title: pull.Title, 1433 + PullId: int64(pull.PullId), 1434 + TargetRepo: string(f.RepoAt), 1435 + TargetBranch: pull.TargetBranch, 1436 + Patch: patch, // new patch 1437 + Source: recordPullSource, 1438 + }, 1439 + }, 1440 + }) 1441 + if err != nil { 1442 + log.Println("failed to update record", err) 1443 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1444 + return 1445 + } 1446 + 1447 + if err = tx.Commit(); err != nil { 1448 + log.Println("failed to commit transaction", err) 1449 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1450 + return 1451 + } 1452 + 1453 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1454 + return 1455 + } 1456 + 1457 + // validate a resubmission against a pull request 1458 + func validateResubmittedPatch(pull *db.Pull, patch string) error { 1459 + if patch == "" { 1460 + return fmt.Errorf("Patch is empty.") 1461 + } 1462 + 1463 + if patch == pull.LatestPatch() { 1464 + return fmt.Errorf("Patch is identical to previous submission.") 1465 + } 1466 + 1467 + if !patchutil.IsPatchValid(patch) { 1468 + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1469 + } 1470 + 1471 + return nil 1472 } 1473 1474 func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { ··· 1493 return 1494 } 1495 1496 + ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1497 + if err != nil { 1498 + log.Printf("resolving identity: %s", err) 1499 + w.WriteHeader(http.StatusNotFound) 1500 + return 1501 + } 1502 + 1503 + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1504 + if err != nil { 1505 + log.Printf("failed to get primary email: %s", err) 1506 + } 1507 + 1508 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1509 if err != nil { 1510 log.Printf("failed to create signed client for %s: %s", f.Knot, err) ··· 1513 } 1514 1515 // Merge the pull request 1516 + resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1517 if err != nil { 1518 log.Printf("failed to merge pull request: %s", err) 1519 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1642 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1643 return 1644 }
+803 -16
appview/state/repo.go
··· 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 - "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" ··· 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/identity" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 securejoin "github.com/cyphar/filepath-securejoin" 20 "github.com/go-chi/chi/v5" 21 "tangled.sh/tangled.sh/core/api/tangled" 22 "tangled.sh/tangled.sh/core/appview/auth" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/types" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 244 if !s.config.Dev { 245 protocol = "https" 246 } 247 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 248 if err != nil { 249 log.Println("failed to reach knotserver", err) ··· 308 user := s.auth.GetUser(r) 309 310 var breadcrumbs [][]string 311 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 312 if treePath != "" { 313 for idx, elem := range strings.Split(treePath, "/") { 314 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 315 } 316 } 317 318 - baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 319 - baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 320 321 s.pages.RepoTree(w, pages.RepoTreeParams{ 322 LoggedInUser: user, ··· 443 } 444 445 var breadcrumbs [][]string 446 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 447 if filePath != "" { 448 for idx, elem := range strings.Split(filePath, "/") { 449 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 450 } 451 } 452 453 user := s.auth.GetUser(r) 454 s.pages.RepoBlob(w, pages.RepoBlobParams{ 455 LoggedInUser: user, 456 RepoInfo: f.RepoInfo(s, user), 457 RepoBlobResponse: result, 458 BreadCrumbs: breadcrumbs, 459 }) 460 return 461 } 462 463 func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 464 f, err := fullyResolvedRepo(r) 465 if err != nil { ··· 519 } 520 }() 521 522 - err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 523 if err != nil { 524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 525 return ··· 549 550 } 551 552 func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 553 f, err := fullyResolvedRepo(r) 554 if err != nil { ··· 567 568 isCollaboratorInviteAllowed := false 569 if user != nil { 570 - ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 571 if err == nil && ok { 572 isCollaboratorInviteAllowed = true 573 } 574 } 575 576 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 577 LoggedInUser: user, 578 RepoInfo: f.RepoInfo(s, user), 579 Collaborators: repoCollaborators, 580 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 581 }) 582 } 583 } ··· 600 } 601 602 func (f *FullyResolvedRepo) OwnerSlashRepo() string { 603 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 604 return p 605 } 606 607 func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 608 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 609 if err != nil { 610 return nil, err 611 } ··· 666 if err != nil { 667 log.Println("failed to get issue count for ", f.RepoAt) 668 } 669 670 knot := f.Knot 671 if knot == "knot1.tangled.sh" { 672 knot = "tangled.sh" 673 } 674 675 - return pages.RepoInfo{ 676 OwnerDid: f.OwnerDid(), 677 OwnerHandle: f.OwnerHandle(), 678 Name: f.RepoName, ··· 686 IssueCount: issueCount, 687 PullCount: pullCount, 688 }, 689 } 690 } 691 692 func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { ··· 784 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 785 Collection: tangled.RepoIssueStateNSID, 786 Repo: user.Did, 787 - Rkey: s.TID(), 788 Record: &lexutil.LexiconTypeDecoder{ 789 Val: &tangled.RepoIssueState{ 790 Issue: issue.IssueAt, ··· 863 } 864 } 865 866 - func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 867 user := s.auth.GetUser(r) 868 f, err := fullyResolvedRepo(r) 869 if err != nil { ··· 887 return 888 } 889 890 - commentId := rand.IntN(1000000) 891 892 - err := db.NewComment(s.db, &db.Comment{ 893 OwnerDid: user.Did, 894 RepoAt: f.RepoAt, 895 Issue: issueIdInt, 896 CommentId: commentId, 897 Body: body, 898 }) 899 if err != nil { 900 log.Println("failed to create comment", err) ··· 917 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 918 Collection: tangled.RepoIssueCommentNSID, 919 Repo: user.Did, 920 - Rkey: s.TID(), 921 Record: &lexutil.LexiconTypeDecoder{ 922 Val: &tangled.RepoIssueComment{ 923 Repo: &atUri, ··· 940 } 941 } 942 943 func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 944 params := r.URL.Query() 945 state := params.Get("state") ··· 951 isOpen = false 952 default: 953 isOpen = true 954 } 955 956 user := s.auth.GetUser(r) ··· 960 return 961 } 962 963 - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 964 if err != nil { 965 log.Println("failed to get issues", err) 966 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 987 Issues: issues, 988 DidHandleMap: didHandleMap, 989 FilteringByOpen: isOpen, 990 }) 991 return 992 } ··· 1045 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1046 Collection: tangled.RepoIssueNSID, 1047 Repo: user.Did, 1048 - Rkey: s.TID(), 1049 Record: &lexutil.LexiconTypeDecoder{ 1050 Val: &tangled.RepoIssue{ 1051 Repo: atUri, ··· 1073 return 1074 } 1075 }
··· 2 3 import ( 4 "context" 5 + "database/sql" 6 "encoding/json" 7 + "errors" 8 "fmt" 9 "io" 10 "log" 11 + mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" ··· 16 "strings" 17 "time" 18 19 + "github.com/bluesky-social/indigo/atproto/data" 20 "github.com/bluesky-social/indigo/atproto/identity" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 securejoin "github.com/cyphar/filepath-securejoin" 23 "github.com/go-chi/chi/v5" 24 + "github.com/go-git/go-git/v5/plumbing" 25 "tangled.sh/tangled.sh/core/api/tangled" 26 + "tangled.sh/tangled.sh/core/appview" 27 "tangled.sh/tangled.sh/core/appview/auth" 28 "tangled.sh/tangled.sh/core/appview/db" 29 "tangled.sh/tangled.sh/core/appview/pages" 30 + "tangled.sh/tangled.sh/core/appview/pages/markup" 31 + "tangled.sh/tangled.sh/core/appview/pagination" 32 "tangled.sh/tangled.sh/core/types" 33 34 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 251 if !s.config.Dev { 252 protocol = "https" 253 } 254 + 255 + if !plumbing.IsHash(ref) { 256 + s.pages.Error404(w) 257 + return 258 + } 259 + 260 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 261 if err != nil { 262 log.Println("failed to reach knotserver", err) ··· 321 user := s.auth.GetUser(r) 322 323 var breadcrumbs [][]string 324 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 325 if treePath != "" { 326 for idx, elem := range strings.Split(treePath, "/") { 327 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 328 } 329 } 330 331 + baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 332 + baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 333 334 s.pages.RepoTree(w, pages.RepoTreeParams{ 335 LoggedInUser: user, ··· 456 } 457 458 var breadcrumbs [][]string 459 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 460 if filePath != "" { 461 for idx, elem := range strings.Split(filePath, "/") { 462 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 463 } 464 } 465 466 + showRendered := false 467 + renderToggle := false 468 + 469 + if markup.GetFormat(result.Path) == markup.FormatMarkdown { 470 + renderToggle = true 471 + showRendered = r.URL.Query().Get("code") != "true" 472 + } 473 + 474 user := s.auth.GetUser(r) 475 s.pages.RepoBlob(w, pages.RepoBlobParams{ 476 LoggedInUser: user, 477 RepoInfo: f.RepoInfo(s, user), 478 RepoBlobResponse: result, 479 BreadCrumbs: breadcrumbs, 480 + ShowRendered: showRendered, 481 + RenderToggle: renderToggle, 482 }) 483 return 484 } 485 486 + func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 487 + f, err := fullyResolvedRepo(r) 488 + if err != nil { 489 + log.Println("failed to get repo and knot", err) 490 + return 491 + } 492 + 493 + ref := chi.URLParam(r, "ref") 494 + filePath := chi.URLParam(r, "*") 495 + 496 + protocol := "http" 497 + if !s.config.Dev { 498 + protocol = "https" 499 + } 500 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 501 + if err != nil { 502 + log.Println("failed to reach knotserver", err) 503 + return 504 + } 505 + 506 + body, err := io.ReadAll(resp.Body) 507 + if err != nil { 508 + log.Printf("Error reading response body: %v", err) 509 + return 510 + } 511 + 512 + var result types.RepoBlobResponse 513 + err = json.Unmarshal(body, &result) 514 + if err != nil { 515 + log.Println("failed to parse response:", err) 516 + return 517 + } 518 + 519 + if result.IsBinary { 520 + w.Header().Set("Content-Type", "application/octet-stream") 521 + w.Write(body) 522 + return 523 + } 524 + 525 + w.Header().Set("Content-Type", "text/plain") 526 + w.Write([]byte(result.Contents)) 527 + return 528 + } 529 + 530 func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 531 f, err := fullyResolvedRepo(r) 532 if err != nil { ··· 586 } 587 }() 588 589 + err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 590 if err != nil { 591 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 592 return ··· 616 617 } 618 619 + func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 620 + user := s.auth.GetUser(r) 621 + 622 + f, err := fullyResolvedRepo(r) 623 + if err != nil { 624 + log.Println("failed to get repo and knot", err) 625 + return 626 + } 627 + 628 + // remove record from pds 629 + xrpcClient, _ := s.auth.AuthorizedClient(r) 630 + repoRkey := f.RepoAt.RecordKey().String() 631 + _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{ 632 + Collection: tangled.RepoNSID, 633 + Repo: user.Did, 634 + Rkey: repoRkey, 635 + }) 636 + if err != nil { 637 + log.Printf("failed to delete record: %s", err) 638 + s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 639 + return 640 + } 641 + log.Println("removed repo record ", f.RepoAt.String()) 642 + 643 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 644 + if err != nil { 645 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 646 + return 647 + } 648 + 649 + ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 650 + if err != nil { 651 + log.Println("failed to create client to ", f.Knot) 652 + return 653 + } 654 + 655 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 656 + if err != nil { 657 + log.Printf("failed to make request to %s: %s", f.Knot, err) 658 + return 659 + } 660 + 661 + if ksResp.StatusCode != http.StatusNoContent { 662 + log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 663 + } else { 664 + log.Println("removed repo from knot ", f.Knot) 665 + } 666 + 667 + tx, err := s.db.BeginTx(r.Context(), nil) 668 + if err != nil { 669 + log.Println("failed to start tx") 670 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 671 + return 672 + } 673 + defer func() { 674 + tx.Rollback() 675 + err = s.enforcer.E.LoadPolicy() 676 + if err != nil { 677 + log.Println("failed to rollback policies") 678 + } 679 + }() 680 + 681 + // remove collaborator RBAC 682 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 683 + if err != nil { 684 + s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 685 + return 686 + } 687 + for _, c := range repoCollaborators { 688 + did := c[0] 689 + s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 690 + } 691 + log.Println("removed collaborators") 692 + 693 + // remove repo RBAC 694 + err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 695 + if err != nil { 696 + s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 697 + return 698 + } 699 + 700 + // remove repo from db 701 + err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 702 + if err != nil { 703 + s.pages.Notice(w, "settings-delete", "Failed to update appview") 704 + return 705 + } 706 + log.Println("removed repo from db") 707 + 708 + err = tx.Commit() 709 + if err != nil { 710 + log.Println("failed to commit changes", err) 711 + http.Error(w, err.Error(), http.StatusInternalServerError) 712 + return 713 + } 714 + 715 + err = s.enforcer.E.SavePolicy() 716 + if err != nil { 717 + log.Println("failed to update ACLs", err) 718 + http.Error(w, err.Error(), http.StatusInternalServerError) 719 + return 720 + } 721 + 722 + s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 723 + } 724 + 725 + func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 726 + f, err := fullyResolvedRepo(r) 727 + if err != nil { 728 + log.Println("failed to get repo and knot", err) 729 + return 730 + } 731 + 732 + branch := r.FormValue("branch") 733 + if branch == "" { 734 + http.Error(w, "malformed form", http.StatusBadRequest) 735 + return 736 + } 737 + 738 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 739 + if err != nil { 740 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 741 + return 742 + } 743 + 744 + ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 745 + if err != nil { 746 + log.Println("failed to create client to ", f.Knot) 747 + return 748 + } 749 + 750 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 751 + if err != nil { 752 + log.Printf("failed to make request to %s: %s", f.Knot, err) 753 + return 754 + } 755 + 756 + if ksResp.StatusCode != http.StatusNoContent { 757 + s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 758 + return 759 + } 760 + 761 + w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 762 + } 763 + 764 func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 765 f, err := fullyResolvedRepo(r) 766 if err != nil { ··· 779 780 isCollaboratorInviteAllowed := false 781 if user != nil { 782 + ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 783 if err == nil && ok { 784 isCollaboratorInviteAllowed = true 785 } 786 } 787 788 + var branchNames []string 789 + var defaultBranch string 790 + us, err := NewUnsignedClient(f.Knot, s.config.Dev) 791 + if err != nil { 792 + log.Println("failed to create unsigned client", err) 793 + } else { 794 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 795 + if err != nil { 796 + log.Println("failed to reach knotserver", err) 797 + } else { 798 + defer resp.Body.Close() 799 + 800 + body, err := io.ReadAll(resp.Body) 801 + if err != nil { 802 + log.Printf("Error reading response body: %v", err) 803 + } else { 804 + var result types.RepoBranchesResponse 805 + err = json.Unmarshal(body, &result) 806 + if err != nil { 807 + log.Println("failed to parse response:", err) 808 + } else { 809 + for _, branch := range result.Branches { 810 + branchNames = append(branchNames, branch.Name) 811 + } 812 + } 813 + } 814 + } 815 + 816 + resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName) 817 + if err != nil { 818 + log.Println("failed to reach knotserver", err) 819 + } else { 820 + defer resp.Body.Close() 821 + 822 + body, err := io.ReadAll(resp.Body) 823 + if err != nil { 824 + log.Printf("Error reading response body: %v", err) 825 + } else { 826 + var result types.RepoDefaultBranchResponse 827 + err = json.Unmarshal(body, &result) 828 + if err != nil { 829 + log.Println("failed to parse response:", err) 830 + } else { 831 + defaultBranch = result.Branch 832 + } 833 + } 834 + } 835 + } 836 + 837 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 838 LoggedInUser: user, 839 RepoInfo: f.RepoInfo(s, user), 840 Collaborators: repoCollaborators, 841 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 842 + Branches: branchNames, 843 + DefaultBranch: defaultBranch, 844 }) 845 } 846 } ··· 863 } 864 865 func (f *FullyResolvedRepo) OwnerSlashRepo() string { 866 + handle := f.OwnerId.Handle 867 + 868 + var p string 869 + if handle != "" && !handle.IsInvalidHandle() { 870 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 871 + } else { 872 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 873 + } 874 + 875 + return p 876 + } 877 + 878 + func (f *FullyResolvedRepo) DidSlashRepo() string { 879 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 880 return p 881 } 882 883 func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 884 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 885 if err != nil { 886 return nil, err 887 } ··· 942 if err != nil { 943 log.Println("failed to get issue count for ", f.RepoAt) 944 } 945 + source, err := db.GetRepoSource(s.db, f.RepoAt) 946 + if errors.Is(err, sql.ErrNoRows) { 947 + source = "" 948 + } else if err != nil { 949 + log.Println("failed to get repo source for ", f.RepoAt, err) 950 + } 951 + 952 + var sourceRepo *db.Repo 953 + if source != "" { 954 + sourceRepo, err = db.GetRepoByAtUri(s.db, source) 955 + if err != nil { 956 + log.Println("failed to get repo by at uri", err) 957 + } 958 + } 959 + 960 + var sourceHandle *identity.Identity 961 + if sourceRepo != nil { 962 + sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 963 + if err != nil { 964 + log.Println("failed to resolve source repo", err) 965 + } 966 + } 967 968 knot := f.Knot 969 + var disableFork bool 970 + us, err := NewUnsignedClient(knot, s.config.Dev) 971 + if err != nil { 972 + log.Printf("failed to create unsigned client for %s: %v", knot, err) 973 + } else { 974 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 975 + if err != nil { 976 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 977 + } else { 978 + defer resp.Body.Close() 979 + body, err := io.ReadAll(resp.Body) 980 + if err != nil { 981 + log.Printf("error reading branch response body: %v", err) 982 + } else { 983 + var branchesResp types.RepoBranchesResponse 984 + if err := json.Unmarshal(body, &branchesResp); err != nil { 985 + log.Printf("error parsing branch response: %v", err) 986 + } else { 987 + disableFork = false 988 + } 989 + 990 + if len(branchesResp.Branches) == 0 { 991 + disableFork = true 992 + } 993 + } 994 + } 995 + } 996 + 997 if knot == "knot1.tangled.sh" { 998 knot = "tangled.sh" 999 } 1000 1001 + repoInfo := pages.RepoInfo{ 1002 OwnerDid: f.OwnerDid(), 1003 OwnerHandle: f.OwnerHandle(), 1004 Name: f.RepoName, ··· 1012 IssueCount: issueCount, 1013 PullCount: pullCount, 1014 }, 1015 + DisableFork: disableFork, 1016 } 1017 + 1018 + if sourceRepo != nil { 1019 + repoInfo.Source = sourceRepo 1020 + repoInfo.SourceHandle = sourceHandle.Handle.String() 1021 + } 1022 + 1023 + return repoInfo 1024 } 1025 1026 func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { ··· 1118 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1119 Collection: tangled.RepoIssueStateNSID, 1120 Repo: user.Did, 1121 + Rkey: appview.TID(), 1122 Record: &lexutil.LexiconTypeDecoder{ 1123 Val: &tangled.RepoIssueState{ 1124 Issue: issue.IssueAt, ··· 1197 } 1198 } 1199 1200 + func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1201 user := s.auth.GetUser(r) 1202 f, err := fullyResolvedRepo(r) 1203 if err != nil { ··· 1221 return 1222 } 1223 1224 + commentId := mathrand.IntN(1000000) 1225 + rkey := appview.TID() 1226 1227 + err := db.NewIssueComment(s.db, &db.Comment{ 1228 OwnerDid: user.Did, 1229 RepoAt: f.RepoAt, 1230 Issue: issueIdInt, 1231 CommentId: commentId, 1232 Body: body, 1233 + Rkey: rkey, 1234 }) 1235 if err != nil { 1236 log.Println("failed to create comment", err) ··· 1253 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1254 Collection: tangled.RepoIssueCommentNSID, 1255 Repo: user.Did, 1256 + Rkey: rkey, 1257 Record: &lexutil.LexiconTypeDecoder{ 1258 Val: &tangled.RepoIssueComment{ 1259 Repo: &atUri, ··· 1276 } 1277 } 1278 1279 + func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1280 + user := s.auth.GetUser(r) 1281 + f, err := fullyResolvedRepo(r) 1282 + if err != nil { 1283 + log.Println("failed to get repo and knot", err) 1284 + return 1285 + } 1286 + 1287 + issueId := chi.URLParam(r, "issue") 1288 + issueIdInt, err := strconv.Atoi(issueId) 1289 + if err != nil { 1290 + http.Error(w, "bad issue id", http.StatusBadRequest) 1291 + log.Println("failed to parse issue id", err) 1292 + return 1293 + } 1294 + 1295 + commentId := chi.URLParam(r, "comment_id") 1296 + commentIdInt, err := strconv.Atoi(commentId) 1297 + if err != nil { 1298 + http.Error(w, "bad comment id", http.StatusBadRequest) 1299 + log.Println("failed to parse issue id", err) 1300 + return 1301 + } 1302 + 1303 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1304 + if err != nil { 1305 + log.Println("failed to get issue", err) 1306 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1307 + return 1308 + } 1309 + 1310 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1311 + if err != nil { 1312 + http.Error(w, "bad comment id", http.StatusBadRequest) 1313 + return 1314 + } 1315 + 1316 + identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1317 + if err != nil { 1318 + log.Println("failed to resolve did") 1319 + return 1320 + } 1321 + 1322 + didHandleMap := make(map[string]string) 1323 + if !identity.Handle.IsInvalidHandle() { 1324 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1325 + } else { 1326 + didHandleMap[identity.DID.String()] = identity.DID.String() 1327 + } 1328 + 1329 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1330 + LoggedInUser: user, 1331 + RepoInfo: f.RepoInfo(s, user), 1332 + DidHandleMap: didHandleMap, 1333 + Issue: issue, 1334 + Comment: comment, 1335 + }) 1336 + } 1337 + 1338 + func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1339 + user := s.auth.GetUser(r) 1340 + f, err := fullyResolvedRepo(r) 1341 + if err != nil { 1342 + log.Println("failed to get repo and knot", err) 1343 + return 1344 + } 1345 + 1346 + issueId := chi.URLParam(r, "issue") 1347 + issueIdInt, err := strconv.Atoi(issueId) 1348 + if err != nil { 1349 + http.Error(w, "bad issue id", http.StatusBadRequest) 1350 + log.Println("failed to parse issue id", err) 1351 + return 1352 + } 1353 + 1354 + commentId := chi.URLParam(r, "comment_id") 1355 + commentIdInt, err := strconv.Atoi(commentId) 1356 + if err != nil { 1357 + http.Error(w, "bad comment id", http.StatusBadRequest) 1358 + log.Println("failed to parse issue id", err) 1359 + return 1360 + } 1361 + 1362 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1363 + if err != nil { 1364 + log.Println("failed to get issue", err) 1365 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1366 + return 1367 + } 1368 + 1369 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1370 + if err != nil { 1371 + http.Error(w, "bad comment id", http.StatusBadRequest) 1372 + return 1373 + } 1374 + 1375 + if comment.OwnerDid != user.Did { 1376 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1377 + return 1378 + } 1379 + 1380 + switch r.Method { 1381 + case http.MethodGet: 1382 + s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1383 + LoggedInUser: user, 1384 + RepoInfo: f.RepoInfo(s, user), 1385 + Issue: issue, 1386 + Comment: comment, 1387 + }) 1388 + case http.MethodPost: 1389 + // extract form value 1390 + newBody := r.FormValue("body") 1391 + client, _ := s.auth.AuthorizedClient(r) 1392 + rkey := comment.Rkey 1393 + 1394 + // optimistic update 1395 + edited := time.Now() 1396 + err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1397 + if err != nil { 1398 + log.Println("failed to perferom update-description query", err) 1399 + s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1400 + return 1401 + } 1402 + 1403 + // rkey is optional, it was introduced later 1404 + if comment.Rkey != "" { 1405 + // update the record on pds 1406 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1407 + if err != nil { 1408 + // failed to get record 1409 + log.Println(err, rkey) 1410 + s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1411 + return 1412 + } 1413 + value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1414 + record, _ := data.UnmarshalJSON(value) 1415 + 1416 + repoAt := record["repo"].(string) 1417 + issueAt := record["issue"].(string) 1418 + createdAt := record["createdAt"].(string) 1419 + commentIdInt64 := int64(commentIdInt) 1420 + 1421 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1422 + Collection: tangled.RepoIssueCommentNSID, 1423 + Repo: user.Did, 1424 + Rkey: rkey, 1425 + SwapRecord: ex.Cid, 1426 + Record: &lexutil.LexiconTypeDecoder{ 1427 + Val: &tangled.RepoIssueComment{ 1428 + Repo: &repoAt, 1429 + Issue: issueAt, 1430 + CommentId: &commentIdInt64, 1431 + Owner: &comment.OwnerDid, 1432 + Body: &newBody, 1433 + CreatedAt: &createdAt, 1434 + }, 1435 + }, 1436 + }) 1437 + if err != nil { 1438 + log.Println(err) 1439 + } 1440 + } 1441 + 1442 + // optimistic update for htmx 1443 + didHandleMap := map[string]string{ 1444 + user.Did: user.Handle, 1445 + } 1446 + comment.Body = newBody 1447 + comment.Edited = &edited 1448 + 1449 + // return new comment body with htmx 1450 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1451 + LoggedInUser: user, 1452 + RepoInfo: f.RepoInfo(s, user), 1453 + DidHandleMap: didHandleMap, 1454 + Issue: issue, 1455 + Comment: comment, 1456 + }) 1457 + return 1458 + 1459 + } 1460 + 1461 + } 1462 + 1463 + func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1464 + user := s.auth.GetUser(r) 1465 + f, err := fullyResolvedRepo(r) 1466 + if err != nil { 1467 + log.Println("failed to get repo and knot", err) 1468 + return 1469 + } 1470 + 1471 + issueId := chi.URLParam(r, "issue") 1472 + issueIdInt, err := strconv.Atoi(issueId) 1473 + if err != nil { 1474 + http.Error(w, "bad issue id", http.StatusBadRequest) 1475 + log.Println("failed to parse issue id", err) 1476 + return 1477 + } 1478 + 1479 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1480 + if err != nil { 1481 + log.Println("failed to get issue", err) 1482 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1483 + return 1484 + } 1485 + 1486 + commentId := chi.URLParam(r, "comment_id") 1487 + commentIdInt, err := strconv.Atoi(commentId) 1488 + if err != nil { 1489 + http.Error(w, "bad comment id", http.StatusBadRequest) 1490 + log.Println("failed to parse issue id", err) 1491 + return 1492 + } 1493 + 1494 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1495 + if err != nil { 1496 + http.Error(w, "bad comment id", http.StatusBadRequest) 1497 + return 1498 + } 1499 + 1500 + if comment.OwnerDid != user.Did { 1501 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1502 + return 1503 + } 1504 + 1505 + if comment.Deleted != nil { 1506 + http.Error(w, "comment already deleted", http.StatusBadRequest) 1507 + return 1508 + } 1509 + 1510 + // optimistic deletion 1511 + deleted := time.Now() 1512 + err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1513 + if err != nil { 1514 + log.Println("failed to delete comment") 1515 + s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1516 + return 1517 + } 1518 + 1519 + // delete from pds 1520 + if comment.Rkey != "" { 1521 + client, _ := s.auth.AuthorizedClient(r) 1522 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1523 + Collection: tangled.GraphFollowNSID, 1524 + Repo: user.Did, 1525 + Rkey: comment.Rkey, 1526 + }) 1527 + if err != nil { 1528 + log.Println(err) 1529 + } 1530 + } 1531 + 1532 + // optimistic update for htmx 1533 + didHandleMap := map[string]string{ 1534 + user.Did: user.Handle, 1535 + } 1536 + comment.Body = "" 1537 + comment.Deleted = &deleted 1538 + 1539 + // htmx fragment of comment after deletion 1540 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1541 + LoggedInUser: user, 1542 + RepoInfo: f.RepoInfo(s, user), 1543 + DidHandleMap: didHandleMap, 1544 + Issue: issue, 1545 + Comment: comment, 1546 + }) 1547 + return 1548 + } 1549 + 1550 func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1551 params := r.URL.Query() 1552 state := params.Get("state") ··· 1558 isOpen = false 1559 default: 1560 isOpen = true 1561 + } 1562 + 1563 + page, ok := r.Context().Value("page").(pagination.Page) 1564 + if !ok { 1565 + log.Println("failed to get page") 1566 + page = pagination.FirstPage() 1567 } 1568 1569 user := s.auth.GetUser(r) ··· 1573 return 1574 } 1575 1576 + issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1577 if err != nil { 1578 log.Println("failed to get issues", err) 1579 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 1600 Issues: issues, 1601 DidHandleMap: didHandleMap, 1602 FilteringByOpen: isOpen, 1603 + Page: page, 1604 }) 1605 return 1606 } ··· 1659 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1660 Collection: tangled.RepoIssueNSID, 1661 Repo: user.Did, 1662 + Rkey: appview.TID(), 1663 Record: &lexutil.LexiconTypeDecoder{ 1664 Val: &tangled.RepoIssue{ 1665 Repo: atUri, ··· 1687 return 1688 } 1689 } 1690 + 1691 + func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1692 + user := s.auth.GetUser(r) 1693 + f, err := fullyResolvedRepo(r) 1694 + if err != nil { 1695 + log.Printf("failed to resolve source repo: %v", err) 1696 + return 1697 + } 1698 + 1699 + switch r.Method { 1700 + case http.MethodGet: 1701 + user := s.auth.GetUser(r) 1702 + knots, err := s.enforcer.GetDomainsForUser(user.Did) 1703 + if err != nil { 1704 + s.pages.Notice(w, "repo", "Invalid user account.") 1705 + return 1706 + } 1707 + 1708 + s.pages.ForkRepo(w, pages.ForkRepoParams{ 1709 + LoggedInUser: user, 1710 + Knots: knots, 1711 + RepoInfo: f.RepoInfo(s, user), 1712 + }) 1713 + 1714 + case http.MethodPost: 1715 + 1716 + knot := r.FormValue("knot") 1717 + if knot == "" { 1718 + s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1719 + return 1720 + } 1721 + 1722 + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1723 + if err != nil || !ok { 1724 + s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1725 + return 1726 + } 1727 + 1728 + forkName := fmt.Sprintf("%s", f.RepoName) 1729 + 1730 + // this check is *only* to see if the forked repo name already exists 1731 + // in the user's account. 1732 + existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1733 + if err != nil { 1734 + if errors.Is(err, sql.ErrNoRows) { 1735 + // no existing repo with this name found, we can use the name as is 1736 + } else { 1737 + log.Println("error fetching existing repo from db", err) 1738 + s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1739 + return 1740 + } 1741 + } else if existingRepo != nil { 1742 + // repo with this name already exists, append random string 1743 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1744 + } 1745 + secret, err := db.GetRegistrationKey(s.db, knot) 1746 + if err != nil { 1747 + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1748 + return 1749 + } 1750 + 1751 + client, err := NewSignedClient(knot, secret, s.config.Dev) 1752 + if err != nil { 1753 + s.pages.Notice(w, "repo", "Failed to reach knot server.") 1754 + return 1755 + } 1756 + 1757 + var uri string 1758 + if s.config.Dev { 1759 + uri = "http" 1760 + } else { 1761 + uri = "https" 1762 + } 1763 + sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1764 + sourceAt := f.RepoAt.String() 1765 + 1766 + rkey := appview.TID() 1767 + repo := &db.Repo{ 1768 + Did: user.Did, 1769 + Name: forkName, 1770 + Knot: knot, 1771 + Rkey: rkey, 1772 + Source: sourceAt, 1773 + } 1774 + 1775 + tx, err := s.db.BeginTx(r.Context(), nil) 1776 + if err != nil { 1777 + log.Println(err) 1778 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1779 + return 1780 + } 1781 + defer func() { 1782 + tx.Rollback() 1783 + err = s.enforcer.E.LoadPolicy() 1784 + if err != nil { 1785 + log.Println("failed to rollback policies") 1786 + } 1787 + }() 1788 + 1789 + resp, err := client.ForkRepo(user.Did, sourceUrl, forkName) 1790 + if err != nil { 1791 + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1792 + return 1793 + } 1794 + 1795 + switch resp.StatusCode { 1796 + case http.StatusConflict: 1797 + s.pages.Notice(w, "repo", "A repository with that name already exists.") 1798 + return 1799 + case http.StatusInternalServerError: 1800 + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1801 + case http.StatusNoContent: 1802 + // continue 1803 + } 1804 + 1805 + xrpcClient, _ := s.auth.AuthorizedClient(r) 1806 + 1807 + addedAt := time.Now().Format(time.RFC3339) 1808 + atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 1809 + Collection: tangled.RepoNSID, 1810 + Repo: user.Did, 1811 + Rkey: rkey, 1812 + Record: &lexutil.LexiconTypeDecoder{ 1813 + Val: &tangled.Repo{ 1814 + Knot: repo.Knot, 1815 + Name: repo.Name, 1816 + AddedAt: &addedAt, 1817 + Owner: user.Did, 1818 + Source: &sourceAt, 1819 + }}, 1820 + }) 1821 + if err != nil { 1822 + log.Printf("failed to create record: %s", err) 1823 + s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1824 + return 1825 + } 1826 + log.Println("created repo record: ", atresp.Uri) 1827 + 1828 + repo.AtUri = atresp.Uri 1829 + err = db.AddRepo(tx, repo) 1830 + if err != nil { 1831 + log.Println(err) 1832 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1833 + return 1834 + } 1835 + 1836 + // acls 1837 + p, _ := securejoin.SecureJoin(user.Did, forkName) 1838 + err = s.enforcer.AddRepo(user.Did, knot, p) 1839 + if err != nil { 1840 + log.Println(err) 1841 + s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1842 + return 1843 + } 1844 + 1845 + err = tx.Commit() 1846 + if err != nil { 1847 + log.Println("failed to commit changes", err) 1848 + http.Error(w, err.Error(), http.StatusInternalServerError) 1849 + return 1850 + } 1851 + 1852 + err = s.enforcer.E.SavePolicy() 1853 + if err != nil { 1854 + log.Println("failed to update ACLs", err) 1855 + http.Error(w, err.Error(), http.StatusInternalServerError) 1856 + return 1857 + } 1858 + 1859 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1860 + return 1861 + } 1862 + }
+15 -1
appview/state/repo_util.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "net/http" 8 9 "github.com/bluesky-social/indigo/atproto/identity" ··· 56 57 func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 58 if u != nil { 59 - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 60 return pages.RolesInRepo{r} 61 } else { 62 return pages.RolesInRepo{} ··· 112 113 return emailToDidOrHandle 114 }
··· 2 3 import ( 4 "context" 5 + "crypto/rand" 6 "fmt" 7 "log" 8 + "math/big" 9 "net/http" 10 11 "github.com/bluesky-social/indigo/atproto/identity" ··· 58 59 func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 60 if u != nil { 61 + r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 62 return pages.RolesInRepo{r} 63 } else { 64 return pages.RolesInRepo{} ··· 114 115 return emailToDidOrHandle 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 + }
+51 -28
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.sh/tangled.sh/core/appview/state/userutil" 9 ) 10 ··· 63 r.Get("/branches", s.RepoBranches) 64 r.Get("/tags", s.RepoTags) 65 r.Get("/blob/{ref}/*", s.RepoBlob) 66 67 r.Route("/issues", func(r chi.Router) { 68 - r.Get("/", s.RepoIssues) 69 r.Get("/{issue}", s.RepoSingleIssue) 70 71 r.Group(func(r chi.Router) { 72 - r.Use(AuthMiddleware(s)) 73 r.Get("/new", s.NewIssue) 74 r.Post("/new", s.NewIssue) 75 - r.Post("/{issue}/comment", s.IssueComment) 76 r.Post("/{issue}/close", s.CloseIssue) 77 r.Post("/{issue}/reopen", s.ReopenIssue) 78 }) 79 }) 80 81 r.Route("/pulls", func(r chi.Router) { 82 r.Get("/", s.RepoPulls) 83 - r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) { 84 r.Get("/", s.NewPull) 85 r.Post("/", s.NewPull) 86 }) 87 ··· 91 92 r.Route("/round/{round}", func(r chi.Router) { 93 r.Get("/", s.RepoPullPatch) 94 r.Get("/actions", s.PullActions) 95 - r.Route("/comment", func(r chi.Router) { 96 r.Get("/", s.PullComment) 97 r.Post("/", s.PullComment) 98 }) 99 }) 100 101 - // authorized requests below this point 102 r.Group(func(r chi.Router) { 103 - r.Use(AuthMiddleware(s)) 104 r.Route("/resubmit", func(r chi.Router) { 105 r.Get("/", s.ResubmitPull) 106 r.Post("/", s.ResubmitPull) 107 }) 108 - r.Route("/comment", func(r chi.Router) { 109 - r.Get("/", s.PullComment) 110 - r.Post("/", s.PullComment) 111 - }) 112 r.Post("/close", s.ClosePull) 113 r.Post("/reopen", s.ReopenPull) 114 // collaborators only ··· 127 128 // settings routes, needs auth 129 r.Group(func(r chi.Router) { 130 - r.Use(AuthMiddleware(s)) 131 // repo description can only be edited by owner 132 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 133 r.Put("/", s.RepoDescription) ··· 137 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 138 r.Get("/", s.RepoSettings) 139 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 140 }) 141 }) 142 }) ··· 156 157 r.Get("/", s.Timeline) 158 159 - r.With(AuthMiddleware(s)).Get("/logout", s.Logout) 160 161 r.Route("/login", func(r chi.Router) { 162 r.Get("/", s.Login) ··· 164 }) 165 166 r.Route("/knots", func(r chi.Router) { 167 - r.Use(AuthMiddleware(s)) 168 r.Get("/", s.Knots) 169 r.Post("/key", s.RegistrationKey) 170 ··· 182 183 r.Route("/repo", func(r chi.Router) { 184 r.Route("/new", func(r chi.Router) { 185 - r.Use(AuthMiddleware(s)) 186 r.Get("/", s.NewRepo) 187 r.Post("/", s.NewRepo) 188 }) 189 // r.Post("/import", s.ImportRepo) 190 }) 191 192 - r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) { 193 r.Post("/", s.Follow) 194 r.Delete("/", s.Follow) 195 }) 196 197 - r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) { 198 r.Post("/", s.Star) 199 r.Delete("/", s.Star) 200 }) 201 202 - r.Route("/settings", func(r chi.Router) { 203 - r.Use(AuthMiddleware(s)) 204 - r.Get("/", s.Settings) 205 - r.Put("/keys", s.SettingsKeys) 206 - r.Delete("/keys", s.SettingsKeys) 207 - r.Put("/emails", s.SettingsEmails) 208 - r.Delete("/emails", s.SettingsEmails) 209 - r.Get("/emails/verify", s.SettingsEmailsVerify) 210 - r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend) 211 - r.Post("/emails/primary", s.SettingsEmailsPrimary) 212 - }) 213 214 r.Get("/keys/{user}", s.Keys) 215 ··· 218 }) 219 return r 220 }
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 + "tangled.sh/tangled.sh/core/appview/middleware" 9 + "tangled.sh/tangled.sh/core/appview/settings" 10 "tangled.sh/tangled.sh/core/appview/state/userutil" 11 ) 12 ··· 65 r.Get("/branches", s.RepoBranches) 66 r.Get("/tags", s.RepoTags) 67 r.Get("/blob/{ref}/*", s.RepoBlob) 68 + r.Get("/blob/{ref}/raw/*", s.RepoBlobRaw) 69 70 r.Route("/issues", func(r chi.Router) { 71 + r.With(middleware.Paginate).Get("/", s.RepoIssues) 72 r.Get("/{issue}", s.RepoSingleIssue) 73 74 r.Group(func(r chi.Router) { 75 + r.Use(middleware.AuthMiddleware(s.auth)) 76 r.Get("/new", s.NewIssue) 77 r.Post("/new", s.NewIssue) 78 + r.Post("/{issue}/comment", s.NewIssueComment) 79 + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 80 + r.Get("/", s.IssueComment) 81 + r.Delete("/", s.DeleteIssueComment) 82 + r.Get("/edit", s.EditIssueComment) 83 + r.Post("/edit", s.EditIssueComment) 84 + }) 85 r.Post("/{issue}/close", s.CloseIssue) 86 r.Post("/{issue}/reopen", s.ReopenIssue) 87 }) 88 }) 89 90 + r.Route("/fork", func(r chi.Router) { 91 + r.Use(middleware.AuthMiddleware(s.auth)) 92 + r.Get("/", s.ForkRepo) 93 + r.Post("/", s.ForkRepo) 94 + }) 95 + 96 r.Route("/pulls", func(r chi.Router) { 97 r.Get("/", s.RepoPulls) 98 + r.With(middleware.AuthMiddleware(s.auth)).Route("/new", func(r chi.Router) { 99 r.Get("/", s.NewPull) 100 + r.Get("/patch-upload", s.PatchUploadFragment) 101 + r.Post("/validate-patch", s.ValidatePatch) 102 + r.Get("/compare-branches", s.CompareBranchesFragment) 103 + r.Get("/compare-forks", s.CompareForksFragment) 104 + r.Get("/fork-branches", s.CompareForksBranchesFragment) 105 r.Post("/", s.NewPull) 106 }) 107 ··· 111 112 r.Route("/round/{round}", func(r chi.Router) { 113 r.Get("/", s.RepoPullPatch) 114 + r.Get("/interdiff", s.RepoPullInterdiff) 115 r.Get("/actions", s.PullActions) 116 + r.With(middleware.AuthMiddleware(s.auth)).Route("/comment", func(r chi.Router) { 117 r.Get("/", s.PullComment) 118 r.Post("/", s.PullComment) 119 }) 120 }) 121 122 + r.Route("/round/{round}.patch", func(r chi.Router) { 123 + r.Get("/", s.RepoPullPatchRaw) 124 + }) 125 + 126 r.Group(func(r chi.Router) { 127 + r.Use(middleware.AuthMiddleware(s.auth)) 128 r.Route("/resubmit", func(r chi.Router) { 129 r.Get("/", s.ResubmitPull) 130 r.Post("/", s.ResubmitPull) 131 }) 132 r.Post("/close", s.ClosePull) 133 r.Post("/reopen", s.ReopenPull) 134 // collaborators only ··· 147 148 // settings routes, needs auth 149 r.Group(func(r chi.Router) { 150 + r.Use(middleware.AuthMiddleware(s.auth)) 151 // repo description can only be edited by owner 152 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 153 r.Put("/", s.RepoDescription) ··· 157 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 158 r.Get("/", s.RepoSettings) 159 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 160 + r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) 161 + r.Put("/branches/default", s.SetDefaultBranch) 162 }) 163 }) 164 }) ··· 178 179 r.Get("/", s.Timeline) 180 181 + r.With(middleware.AuthMiddleware(s.auth)).Post("/logout", s.Logout) 182 183 r.Route("/login", func(r chi.Router) { 184 r.Get("/", s.Login) ··· 186 }) 187 188 r.Route("/knots", func(r chi.Router) { 189 + r.Use(middleware.AuthMiddleware(s.auth)) 190 r.Get("/", s.Knots) 191 r.Post("/key", s.RegistrationKey) 192 ··· 204 205 r.Route("/repo", func(r chi.Router) { 206 r.Route("/new", func(r chi.Router) { 207 + r.Use(middleware.AuthMiddleware(s.auth)) 208 r.Get("/", s.NewRepo) 209 r.Post("/", s.NewRepo) 210 }) 211 // r.Post("/import", s.ImportRepo) 212 }) 213 214 + r.With(middleware.AuthMiddleware(s.auth)).Route("/follow", func(r chi.Router) { 215 r.Post("/", s.Follow) 216 r.Delete("/", s.Follow) 217 }) 218 219 + r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) { 220 r.Post("/", s.Star) 221 r.Delete("/", s.Star) 222 }) 223 224 + r.Mount("/settings", s.SettingsRouter()) 225 226 r.Get("/keys/{user}", s.Keys) 227 ··· 230 }) 231 return r 232 } 233 + 234 + func (s *State) SettingsRouter() http.Handler { 235 + settings := &settings.Settings{ 236 + Db: s.db, 237 + Auth: s.auth, 238 + Pages: s.pages, 239 + Config: s.config, 240 + } 241 + 242 + return settings.Router() 243 + }
-416
appview/state/settings.go
··· 1 - package state 2 - 3 - import ( 4 - "database/sql" 5 - "errors" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/gliderlabs/ssh" 16 - "github.com/google/uuid" 17 - "tangled.sh/tangled.sh/core/api/tangled" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/email" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - ) 22 - 23 - func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 24 - user := s.auth.GetUser(r) 25 - pubKeys, err := db.GetPublicKeys(s.db, user.Did) 26 - if err != nil { 27 - log.Println(err) 28 - } 29 - 30 - emails, err := db.GetAllEmails(s.db, user.Did) 31 - if err != nil { 32 - log.Println(err) 33 - } 34 - 35 - s.pages.Settings(w, pages.SettingsParams{ 36 - LoggedInUser: user, 37 - PubKeys: pubKeys, 38 - Emails: emails, 39 - }) 40 - } 41 - 42 - // buildVerificationEmail creates an email.Email struct for verification emails 43 - func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email { 44 - verifyURL := s.verifyUrl(did, emailAddr, code) 45 - 46 - return email.Email{ 47 - APIKey: s.config.ResendApiKey, 48 - From: "noreply@notifs.tangled.sh", 49 - To: emailAddr, 50 - Subject: "Verify your Tangled email", 51 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 52 - ` + verifyURL, 53 - Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 54 - <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 55 - } 56 - } 57 - 58 - // sendVerificationEmail handles the common logic for sending verification emails 59 - func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 60 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 61 - 62 - err := email.SendEmail(emailToSend) 63 - if err != nil { 64 - log.Printf("sending email: %s", err) 65 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 66 - return err 67 - } 68 - 69 - return nil 70 - } 71 - 72 - func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 73 - switch r.Method { 74 - case http.MethodGet: 75 - s.pages.Notice(w, "settings-emails", "Unimplemented.") 76 - log.Println("unimplemented") 77 - return 78 - case http.MethodPut: 79 - did := s.auth.GetDid(r) 80 - emAddr := r.FormValue("email") 81 - emAddr = strings.TrimSpace(emAddr) 82 - 83 - if !email.IsValidEmail(emAddr) { 84 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 85 - return 86 - } 87 - 88 - // check if email already exists in database 89 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 90 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 91 - log.Printf("checking for existing email: %s", err) 92 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 93 - return 94 - } 95 - 96 - if err == nil { 97 - if existingEmail.Verified { 98 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 99 - return 100 - } 101 - 102 - s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 103 - return 104 - } 105 - 106 - code := uuid.New().String() 107 - 108 - // Begin transaction 109 - tx, err := s.db.Begin() 110 - if err != nil { 111 - log.Printf("failed to start transaction: %s", err) 112 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 113 - return 114 - } 115 - defer tx.Rollback() 116 - 117 - if err := db.AddEmail(tx, db.Email{ 118 - Did: did, 119 - Address: emAddr, 120 - Verified: false, 121 - VerificationCode: code, 122 - }); err != nil { 123 - log.Printf("adding email: %s", err) 124 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 125 - return 126 - } 127 - 128 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 129 - return 130 - } 131 - 132 - // Commit transaction 133 - if err := tx.Commit(); err != nil { 134 - log.Printf("failed to commit transaction: %s", err) 135 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 136 - return 137 - } 138 - 139 - s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 140 - return 141 - case http.MethodDelete: 142 - did := s.auth.GetDid(r) 143 - emailAddr := r.FormValue("email") 144 - emailAddr = strings.TrimSpace(emailAddr) 145 - 146 - // Begin transaction 147 - tx, err := s.db.Begin() 148 - if err != nil { 149 - log.Printf("failed to start transaction: %s", err) 150 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 151 - return 152 - } 153 - defer tx.Rollback() 154 - 155 - if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 156 - log.Printf("deleting email: %s", err) 157 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 158 - return 159 - } 160 - 161 - // Commit transaction 162 - if err := tx.Commit(); err != nil { 163 - log.Printf("failed to commit transaction: %s", err) 164 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 165 - return 166 - } 167 - 168 - s.pages.HxLocation(w, "/settings") 169 - return 170 - } 171 - } 172 - 173 - func (s *State) verifyUrl(did string, email string, code string) string { 174 - var appUrl string 175 - if s.config.Dev { 176 - appUrl = "http://" + s.config.ListenAddr 177 - } else { 178 - appUrl = "https://tangled.sh" 179 - } 180 - 181 - return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 182 - } 183 - 184 - func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 185 - q := r.URL.Query() 186 - 187 - // Get the parameters directly from the query 188 - emailAddr := q.Get("email") 189 - did := q.Get("did") 190 - code := q.Get("code") 191 - 192 - valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 193 - if err != nil { 194 - log.Printf("checking email verification: %s", err) 195 - s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 196 - return 197 - } 198 - 199 - if !valid { 200 - s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 201 - return 202 - } 203 - 204 - // Mark email as verified in the database 205 - if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 206 - log.Printf("marking email as verified: %s", err) 207 - s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 208 - return 209 - } 210 - 211 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 212 - } 213 - 214 - func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) { 215 - if r.Method != http.MethodPost { 216 - s.pages.Notice(w, "settings-emails-error", "Invalid request method.") 217 - return 218 - } 219 - 220 - did := s.auth.GetDid(r) 221 - emAddr := r.FormValue("email") 222 - emAddr = strings.TrimSpace(emAddr) 223 - 224 - if !email.IsValidEmail(emAddr) { 225 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 226 - return 227 - } 228 - 229 - // Check if email exists and is unverified 230 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 231 - if err != nil { 232 - if errors.Is(err, sql.ErrNoRows) { 233 - s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 234 - } else { 235 - log.Printf("checking for existing email: %s", err) 236 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 237 - } 238 - return 239 - } 240 - 241 - if existingEmail.Verified { 242 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 243 - return 244 - } 245 - 246 - // Check if last verification email was sent less than 10 minutes ago 247 - if existingEmail.LastSent != nil { 248 - timeSinceLastSent := time.Since(*existingEmail.LastSent) 249 - if timeSinceLastSent < 10*time.Minute { 250 - waitTime := 10*time.Minute - timeSinceLastSent 251 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 252 - return 253 - } 254 - } 255 - 256 - // Generate new verification code 257 - code := uuid.New().String() 258 - 259 - // Begin transaction 260 - tx, err := s.db.Begin() 261 - if err != nil { 262 - log.Printf("failed to start transaction: %s", err) 263 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 264 - return 265 - } 266 - defer tx.Rollback() 267 - 268 - // Update the verification code and last sent time 269 - if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 270 - log.Printf("updating email verification: %s", err) 271 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 - return 273 - } 274 - 275 - // Send verification email 276 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 277 - return 278 - } 279 - 280 - // Commit transaction 281 - if err := tx.Commit(); err != nil { 282 - log.Printf("failed to commit transaction: %s", err) 283 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 284 - return 285 - } 286 - 287 - s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 288 - } 289 - 290 - func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 291 - did := s.auth.GetDid(r) 292 - emailAddr := r.FormValue("email") 293 - emailAddr = strings.TrimSpace(emailAddr) 294 - 295 - if emailAddr == "" { 296 - s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 297 - return 298 - } 299 - 300 - if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 301 - log.Printf("setting primary email: %s", err) 302 - s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 303 - return 304 - } 305 - 306 - s.pages.HxLocation(w, "/settings") 307 - } 308 - 309 - func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { 310 - switch r.Method { 311 - case http.MethodGet: 312 - s.pages.Notice(w, "settings-keys", "Unimplemented.") 313 - log.Println("unimplemented") 314 - return 315 - case http.MethodPut: 316 - did := s.auth.GetDid(r) 317 - key := r.FormValue("key") 318 - key = strings.TrimSpace(key) 319 - name := r.FormValue("name") 320 - client, _ := s.auth.AuthorizedClient(r) 321 - 322 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 323 - if err != nil { 324 - log.Printf("parsing public key: %s", err) 325 - s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 326 - return 327 - } 328 - 329 - rkey := s.TID() 330 - 331 - tx, err := s.db.Begin() 332 - if err != nil { 333 - log.Printf("failed to start tx; adding public key: %s", err) 334 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 335 - return 336 - } 337 - defer tx.Rollback() 338 - 339 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 340 - log.Printf("adding public key: %s", err) 341 - s.pages.Notice(w, "settings-keys", "Failed to add public key.") 342 - return 343 - } 344 - 345 - // store in pds too 346 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 347 - Collection: tangled.PublicKeyNSID, 348 - Repo: did, 349 - Rkey: rkey, 350 - Record: &lexutil.LexiconTypeDecoder{ 351 - Val: &tangled.PublicKey{ 352 - Created: time.Now().Format(time.RFC3339), 353 - Key: key, 354 - Name: name, 355 - }}, 356 - }) 357 - // invalid record 358 - if err != nil { 359 - log.Printf("failed to create record: %s", err) 360 - s.pages.Notice(w, "settings-keys", "Failed to create record.") 361 - return 362 - } 363 - 364 - log.Println("created atproto record: ", resp.Uri) 365 - 366 - err = tx.Commit() 367 - if err != nil { 368 - log.Printf("failed to commit tx; adding public key: %s", err) 369 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 - return 371 - } 372 - 373 - s.pages.HxLocation(w, "/settings") 374 - return 375 - 376 - case http.MethodDelete: 377 - did := s.auth.GetDid(r) 378 - q := r.URL.Query() 379 - 380 - name := q.Get("name") 381 - rkey := q.Get("rkey") 382 - key := q.Get("key") 383 - 384 - log.Println(name) 385 - log.Println(rkey) 386 - log.Println(key) 387 - 388 - client, _ := s.auth.AuthorizedClient(r) 389 - 390 - if err := db.RemovePublicKey(s.db, did, name, key); err != nil { 391 - log.Printf("removing public key: %s", err) 392 - s.pages.Notice(w, "settings-keys", "Failed to remove public key.") 393 - return 394 - } 395 - 396 - if rkey != "" { 397 - // remove from pds too 398 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 399 - Collection: tangled.PublicKeyNSID, 400 - Repo: did, 401 - Rkey: rkey, 402 - }) 403 - 404 - // invalid record 405 - if err != nil { 406 - log.Printf("failed to delete record from PDS: %s", err) 407 - s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 408 - return 409 - } 410 - } 411 - log.Println("deleted successfully") 412 - 413 - s.pages.HxLocation(w, "/settings") 414 - return 415 - } 416 - }
···
+150
appview/state/signer.go
··· 7 "encoding/hex" 8 "encoding/json" 9 "fmt" 10 "net/http" 11 "net/url" 12 "time" ··· 103 return s.client.Do(req) 104 } 105 106 func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 107 const ( 108 Method = "DELETE" ··· 140 return s.client.Do(req) 141 } 142 143 func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 144 const ( 145 Method = "POST" ··· 205 return s.client.Do(req) 206 } 207 208 type UnsignedClient struct { 209 Url *url.URL 210 client *http.Client ··· 268 269 return us.client.Do(req) 270 }
··· 7 "encoding/hex" 8 "encoding/json" 9 "fmt" 10 + "io" 11 + "log" 12 "net/http" 13 "net/url" 14 "time" ··· 105 return s.client.Do(req) 106 } 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 + 128 func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 129 const ( 130 Method = "DELETE" ··· 162 return s.client.Do(req) 163 } 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 + 183 func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 184 const ( 185 Method = "POST" ··· 245 return s.client.Do(req) 246 } 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 + 262 type UnsignedClient struct { 263 Url *url.URL 264 client *http.Client ··· 322 323 return us.client.Do(req) 324 } 325 + 326 + func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) { 327 + const ( 328 + Method = "GET" 329 + ) 330 + 331 + endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, branch) 332 + 333 + req, err := us.newRequest(Method, endpoint, nil) 334 + if err != nil { 335 + return nil, err 336 + } 337 + 338 + return us.client.Do(req) 339 + } 340 + 341 + func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*http.Response, error) { 342 + const ( 343 + Method = "GET" 344 + ) 345 + 346 + endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 347 + 348 + req, err := us.newRequest(Method, endpoint, nil) 349 + if err != nil { 350 + return nil, err 351 + } 352 + 353 + return us.client.Do(req) 354 + } 355 + 356 + func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 357 + const ( 358 + Method = "GET" 359 + Endpoint = "/capabilities" 360 + ) 361 + 362 + req, err := us.newRequest(Method, Endpoint, nil) 363 + if err != nil { 364 + return nil, err 365 + } 366 + 367 + resp, err := us.client.Do(req) 368 + if err != nil { 369 + return nil, err 370 + } 371 + defer resp.Body.Close() 372 + 373 + var capabilities types.Capabilities 374 + if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 375 + return nil, err 376 + } 377 + 378 + return &capabilities, nil 379 + } 380 + 381 + func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 382 + const ( 383 + Method = "GET" 384 + ) 385 + 386 + endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 387 + 388 + req, err := us.newRequest(Method, endpoint, nil) 389 + if err != nil { 390 + return nil, fmt.Errorf("Failed to create request.") 391 + } 392 + 393 + compareResp, err := us.client.Do(req) 394 + if err != nil { 395 + return nil, fmt.Errorf("Failed to create request.") 396 + } 397 + defer compareResp.Body.Close() 398 + 399 + switch compareResp.StatusCode { 400 + case 404: 401 + case 400: 402 + return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 403 + } 404 + 405 + respBody, err := io.ReadAll(compareResp.Body) 406 + if err != nil { 407 + log.Println("failed to compare across branches") 408 + return nil, fmt.Errorf("Failed to compare branches.") 409 + } 410 + defer compareResp.Body.Close() 411 + 412 + var formatPatchResponse types.RepoFormatPatchResponse 413 + err = json.Unmarshal(respBody, &formatPatchResponse) 414 + if err != nil { 415 + log.Println("failed to unmarshal format-patch response", err) 416 + return nil, fmt.Errorf("failed to compare branches.") 417 + } 418 + 419 + return &formatPatchResponse, nil 420 + }
+4 -3
appview/state/star.go
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 tangled "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) ··· 33 switch r.Method { 34 case http.MethodPost: 35 createdAt := time.Now().Format(time.RFC3339) 36 - rkey := s.TID() 37 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 38 Collection: tangled.FeedStarNSID, 39 Repo: currentUser.Did, ··· 62 63 log.Println("created atproto record: ", resp.Uri) 64 65 - s.pages.StarFragment(w, pages.StarFragmentParams{ 66 IsStarred: true, 67 RepoAt: subjectUri, 68 Stats: db.RepoStats{ ··· 101 log.Println("failed to get star count for ", subjectUri) 102 } 103 104 - s.pages.StarFragment(w, pages.StarFragmentParams{ 105 IsStarred: false, 106 RepoAt: subjectUri, 107 Stats: db.RepoStats{
··· 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 tangled "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 ) ··· 34 switch r.Method { 35 case http.MethodPost: 36 createdAt := time.Now().Format(time.RFC3339) 37 + rkey := appview.TID() 38 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 39 Collection: tangled.FeedStarNSID, 40 Repo: currentUser.Did, ··· 63 64 log.Println("created atproto record: ", resp.Uri) 65 66 + s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 67 IsStarred: true, 68 RepoAt: subjectUri, 69 Stats: db.RepoStats{ ··· 102 log.Println("failed to get star count for ", subjectUri) 103 } 104 105 + s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 106 IsStarred: false, 107 RepoAt: subjectUri, 108 Stats: db.RepoStats{
+35 -76
appview/state/state.go
··· 55 56 clock := syntax.NewTIDClock(0) 57 58 - pgs := pages.NewPages() 59 60 resolver := appview.NewResolver() 61 62 wrapper := db.DbWrapper{d} 63 - jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false) 64 if err != nil { 65 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 66 } ··· 83 return state, nil 84 } 85 86 - func (s *State) TID() string { 87 - return s.tidClock.Next().String() 88 } 89 90 func (s *State) Login(w http.ResponseWriter, r *http.Request) { ··· 165 166 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 167 s.auth.ClearSession(r, w) 168 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 169 } 170 171 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 181 for _, ev := range timeline { 182 if ev.Repo != nil { 183 didsToResolve = append(didsToResolve, ev.Repo.Did) 184 } 185 if ev.Follow != nil { 186 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) ··· 419 } 420 } 421 422 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 423 isOwner := err == nil && ok 424 425 p := pages.KnotParams{ 426 LoggedInUser: user, 427 Registration: reg, 428 Members: members, 429 IsOwner: isOwner, ··· 494 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 495 Collection: tangled.KnotMemberNSID, 496 Repo: currentUser.Did, 497 - Rkey: s.TID(), 498 Record: &lexutil.LexiconTypeDecoder{ 499 Val: &tangled.KnotMember{ 500 Member: memberIdent.DID.String(), ··· 618 return 619 } 620 621 - rkey := s.TID() 622 repo := &db.Repo{ 623 Did: user.Did, 624 Name: repoName, ··· 713 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 714 return 715 } 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 } 786 787 func GetAvatarUri(handle string) (string, error) {
··· 55 56 clock := syntax.NewTIDClock(0) 57 58 + pgs := pages.NewPages(config.Dev) 59 60 resolver := appview.NewResolver() 61 62 wrapper := db.DbWrapper{d} 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 + ) 72 if err != nil { 73 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 74 } ··· 91 return state, nil 92 } 93 94 + func TID(c *syntax.TIDClock) string { 95 + return c.Next().String() 96 } 97 98 func (s *State) Login(w http.ResponseWriter, r *http.Request) { ··· 173 174 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 175 s.auth.ClearSession(r, w) 176 + w.Header().Set("HX-Redirect", "/login") 177 + w.WriteHeader(http.StatusSeeOther) 178 } 179 180 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 190 for _, ev := range timeline { 191 if ev.Repo != nil { 192 didsToResolve = append(didsToResolve, ev.Repo.Did) 193 + if ev.Source != nil { 194 + didsToResolve = append(didsToResolve, ev.Source.Did) 195 + } 196 } 197 if ev.Follow != nil { 198 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) ··· 431 } 432 } 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 + 449 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 450 isOwner := err == nil && ok 451 452 p := pages.KnotParams{ 453 LoggedInUser: user, 454 + DidHandleMap: didHandleMap, 455 Registration: reg, 456 Members: members, 457 IsOwner: isOwner, ··· 522 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 523 Collection: tangled.KnotMemberNSID, 524 Repo: currentUser.Did, 525 + Rkey: appview.TID(), 526 Record: &lexutil.LexiconTypeDecoder{ 527 Val: &tangled.KnotMember{ 528 Member: memberIdent.DID.String(), ··· 646 return 647 } 648 649 + rkey := appview.TID() 650 repo := &db.Repo{ 651 Did: user.Did, 652 Name: repoName, ··· 741 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 742 return 743 } 744 } 745 746 func GetAvatarUri(handle string) (string, error) {
+11
appview/tid.go
···
··· 1 + package appview 2 + 3 + import ( 4 + "github.com/bluesky-social/indigo/atproto/syntax" 5 + ) 6 + 7 + var c *syntax.TIDClock = syntax.NewTIDClock(0) 8 + 9 + func TID() string { 10 + return c.Next().String() 11 + }
+38
cmd/combinediff/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: combinediff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + combined := patchutil.CombineDiff(files1, files2) 37 + fmt.Println(combined) 38 + }
+1
cmd/gen.go
··· 23 shtangled.RepoIssue{}, 24 shtangled.Repo{}, 25 shtangled.RepoPull{}, 26 shtangled.RepoPullStatus{}, 27 shtangled.RepoPullComment{}, 28 ); err != nil {
··· 23 shtangled.RepoIssue{}, 24 shtangled.Repo{}, 25 shtangled.RepoPull{}, 26 + shtangled.RepoPull_Source{}, 27 shtangled.RepoPullStatus{}, 28 shtangled.RepoPullComment{}, 29 ); err != nil {
+38
cmd/interdiff/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: interdiff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + interDiffResult := patchutil.Interdiff(files1, files2) 37 + fmt.Println(interDiffResult) 38 + }
-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
··· 49 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 50 tangled.PublicKeyNSID, 51 tangled.KnotMemberNSID, 52 - }, nil, l, db, false) 53 if err != nil { 54 l.Error("failed to setup jetstream", "error", err) 55 }
··· 49 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 50 tangled.PublicKeyNSID, 51 tangled.KnotMemberNSID, 52 + }, nil, l, db, true) 53 if err != nil { 54 l.Error("failed to setup jetstream", "error", err) 55 }
+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
···
··· 1 + services: 2 + knot: 3 + build: 4 + context: .. 5 + dockerfile: docker/Dockerfile 6 + environment: 7 + KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME} 8 + KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET} 9 + KNOT_SERVER_DB_PATH: "/app/knotserver.db" 10 + KNOT_REPO_SCAN_PATH: "/home/git/repositories" 11 + volumes: 12 + - "./keys:/etc/ssh/keys" 13 + - "./repositories:/home/git/repositories" 14 + - "./server:/app" 15 + ports: 16 + - "5555:5555" 17 + - "2222:22"
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
··· 1 + oneshot
+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

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
···
··· 1 + #!/command/with-contenv ash 2 + 3 + exec s6-setuidgid git /usr/local/bin/knotserver
+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

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

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
···
··· 1 + #!/usr/bin/execlineb -P 2 + 3 + /usr/sbin/sshd -e -D
+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

This is a binary file and will not be displayed.

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
···
··· 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
···
··· 1 + HostKey /etc/ssh/keys/ssh_host_rsa_key 2 + HostKey /etc/ssh/keys/ssh_host_ecdsa_key 3 + HostKey /etc/ssh/keys/ssh_host_ed25519_key 4 + 5 + PasswordAuthentication no 6 + 7 + Match User git 8 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories 9 + AuthorizedKeysCommandUser nobody
+76
docs/contributing.md
···
··· 1 + # tangled contributing guide 2 + 3 + ## commit guidelines 4 + 5 + We follow a commit style similar to the Go project. Please keep commits: 6 + 7 + * **atomic**: each commit should represent one logical change 8 + * **descriptive**: the commit message should clearly describe what the 9 + change does and why it's needed 10 + 11 + ### message format 12 + 13 + ``` 14 + <service/top-level directory>: <affected package/directory>: <short summary of change> 15 + 16 + 17 + Optional longer description can go here, if necessary. Explain what the 18 + change does and why, especially if not obvious. Reference relevant 19 + issues or PRs when applicable. These can be links for now since we don't 20 + auto-link issues/PRs yet. 21 + ``` 22 + 23 + Here are some examples: 24 + 25 + ``` 26 + appview: state: fix token expiry check in middleware 27 + 28 + The previous check did not account for clock drift, leading to premature 29 + token invalidation. 30 + ``` 31 + 32 + ``` 33 + knotserver: git/service: improve error checking in upload-pack 34 + ``` 35 + 36 + ### general notes 37 + 38 + - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 39 + using `git am`. At present, there is no squashing -- so please author 40 + your commits as they would appear on `master`, following the above 41 + guidelines. 42 + - Use the imperative mood in the summary line (e.g., "fix bug" not 43 + "fixed bug" or "fixes bug"). 44 + - Try to keep the summary line under 72 characters, but we aren't too 45 + fussed about this. 46 + - Don't include unrelated changes in the same commit. 47 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 48 + before submitting if necessary. 49 + 50 + ## proposals for bigger changes 51 + 52 + Small fixes like typos, minor bugs, or trivial refactors can be 53 + submitted directly as PRs. 54 + 55 + For larger changesโ€”especially those introducing new features, 56 + significant refactoring, or altering system behaviorโ€”please open a 57 + proposal first. This helps us evaluate the scope, design, and potential 58 + impact before implementation. 59 + 60 + ### proposal format 61 + 62 + Create a new issue titled: 63 + 64 + ``` 65 + proposal: <affected scope>: <summary of change> 66 + ``` 67 + 68 + In the description, explain: 69 + 70 + - What the change is 71 + - Why it's needed 72 + - How you plan to implement it (roughly) 73 + - Any open questions or tradeoffs 74 + 75 + We'll use the issue thread to discuss and refine the idea before moving 76 + forward.
+108
docs/knot-hosting.md
···
··· 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.
+29 -17
flake.lock
··· 32 "url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 33 } 34 }, 35 - "ia-fonts-src": { 36 "flake": false, 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" 44 }, 45 "original": { 46 - "owner": "iaolo", 47 - "repo": "iA-Fonts", 48 - "type": "github" 49 } 50 }, 51 "indigo": { 52 "flake": false, 53 "locked": { 54 - "lastModified": 1738491661, 55 - "narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=", 56 "owner": "oppiliappan", 57 "repo": "indigo", 58 - "rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71", 59 "type": "github" 60 }, 61 "original": { ··· 64 "type": "github" 65 } 66 }, 67 "lucide-src": { 68 "flake": false, 69 "locked": { ··· 79 }, 80 "nixpkgs": { 81 "locked": { 82 - "lastModified": 1740938536, 83 - "narHash": "sha256-m6Lz7cRoZ8GS7tziYrNWv0WXTYtKx3oOC9Bwa6a13EA=", 84 "owner": "nixos", 85 "repo": "nixpkgs", 86 - "rev": "2ffed2bc3d27861b821f9bec127cf51a4dbfabb4", 87 "type": "github" 88 }, 89 "original": { 90 "owner": "nixos", 91 "repo": "nixpkgs", 92 "type": "github" 93 } ··· 96 "inputs": { 97 "gitignore": "gitignore", 98 "htmx-src": "htmx-src", 99 - "ia-fonts-src": "ia-fonts-src", 100 "indigo": "indigo", 101 "lucide-src": "lucide-src", 102 "nixpkgs": "nixpkgs" 103 }
··· 32 "url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 33 } 34 }, 35 + "ibm-plex-mono-src": { 36 "flake": false, 37 "locked": { 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" 42 }, 43 "original": { 44 + "type": "tarball", 45 + "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" 46 } 47 }, 48 "indigo": { 49 "flake": false, 50 "locked": { 51 + "lastModified": 1745333930, 52 + "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 53 "owner": "oppiliappan", 54 "repo": "indigo", 55 + "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 56 "type": "github" 57 }, 58 "original": { ··· 61 "type": "github" 62 } 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 + }, 77 "lucide-src": { 78 "flake": false, 79 "locked": { ··· 89 }, 90 "nixpkgs": { 91 "locked": { 92 + "lastModified": 1743813633, 93 + "narHash": "sha256-BgkBz4NpV6Kg8XF7cmHDHRVGZYnKbvG0Y4p+jElwxaM=", 94 "owner": "nixos", 95 "repo": "nixpkgs", 96 + "rev": "7819a0d29d1dd2bc331bec4b327f0776359b1fa6", 97 "type": "github" 98 }, 99 "original": { 100 "owner": "nixos", 101 + "ref": "nixos-24.11", 102 "repo": "nixpkgs", 103 "type": "github" 104 } ··· 107 "inputs": { 108 "gitignore": "gitignore", 109 "htmx-src": "htmx-src", 110 + "ibm-plex-mono-src": "ibm-plex-mono-src", 111 "indigo": "indigo", 112 + "inter-fonts-src": "inter-fonts-src", 113 "lucide-src": "lucide-src", 114 "nixpkgs": "nixpkgs" 115 }
+82 -38
flake.nix
··· 2 description = "atproto github"; 3 4 inputs = { 5 - nixpkgs.url = "github:nixos/nixpkgs"; 6 indigo = { 7 url = "github:oppiliappan/indigo"; 8 flake = false; ··· 15 url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 16 flake = false; 17 }; 18 - ia-fonts-src = { 19 - url = "github:iaolo/iA-Fonts"; 20 flake = false; 21 }; 22 gitignore = { ··· 32 htmx-src, 33 lucide-src, 34 gitignore, 35 - ia-fonts-src, 36 }: let 37 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 38 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 44 inherit (gitignore.lib) gitignoreSource; 45 in { 46 overlays.default = final: prev: let 47 - goModHash = "sha256-3gmXhututsJTFVPQi2uekTBP/qSJGgsDsVr7YU+z7d0="; 48 buildCmdPackage = name: 49 final.buildGoModule { 50 pname = name; ··· 52 src = gitignoreSource ./.; 53 subPackages = ["cmd/${name}"]; 54 vendorHash = goModHash; 55 - env.CGO_ENABLED = 0; 56 }; 57 in { 58 indigo-lexgen = final.buildGoModule { ··· 74 mkdir -p appview/pages/static/{fonts,icons} 75 cp -f ${htmx-src} appview/pages/static/htmx.min.js 76 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/ 79 ${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 80 popd 81 ''; 82 doCheck = false; 83 subPackages = ["cmd/appview"]; 84 vendorHash = goModHash; 85 - env.CGO_ENABLED = 1; 86 stdenv = pkgsStatic.stdenv; 87 }; 88 ··· 105 106 runHook postInstall 107 ''; 108 - env.CGO_ENABLED = 1; 109 }; 110 knotserver-unwrapped = final.pkgsStatic.buildGoModule { 111 pname = "knotserver"; ··· 113 src = gitignoreSource ./.; 114 subPackages = ["cmd/knotserver"]; 115 vendorHash = goModHash; 116 - env.CGO_ENABLED = 1; 117 }; 118 repoguard = buildCmdPackage "repoguard"; 119 keyfetch = buildCmdPackage "keyfetch"; ··· 153 mkdir -p appview/pages/static/{fonts,icons} 154 cp -f ${htmx-src} appview/pages/static/htmx.min.js 155 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/ 158 ''; 159 }; 160 }); ··· 166 ${pkgs.air}/bin/air -c /dev/null \ 167 -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 168 -build.bin "./out/${name}.out" \ 169 - -build.include_ext "go,html,css" 170 ''; 171 in { 172 watch-appview = { ··· 176 watch-knotserver = { 177 type = "app"; 178 program = ''${air-watcher "knotserver"}/bin/run''; 179 }; 180 }); 181 ··· 230 pkgs, 231 lib, 232 ... 233 - }: 234 with lib; { 235 options = { 236 services.tangled-knotserver = { ··· 250 type = types.str; 251 default = "git"; 252 description = "User that hosts git repos and performs git operations"; 253 }; 254 255 repo = { 256 scanPath = mkOption { 257 type = types.path; 258 - default = "/home/git"; 259 description = "Path where repositories are scanned from"; 260 }; 261 ··· 287 288 dbPath = mkOption { 289 type = types.path; 290 - default = "knotserver.db"; 291 description = "Path to the database file"; 292 }; 293 ··· 306 }; 307 }; 308 309 - config = mkIf config.services.tangled-knotserver.enable { 310 environment.systemPackages = with pkgs; [git]; 311 312 system.activationScripts.gitConfig = '' 313 - mkdir -p /home/git/.config/git 314 - cat > /home/git/.config/git/config << EOF 315 [user] 316 name = Git User 317 email = git@example.com 318 EOF 319 - chown -R git:git /home/git/.config 320 ''; 321 322 - users.users.git = { 323 - isNormalUser = true; 324 - home = "/home/git"; 325 createHome = true; 326 - group = "git"; 327 }; 328 329 - users.groups.git = {}; 330 331 services.openssh = { 332 enable = true; 333 extraConfig = '' 334 - Match User git 335 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 336 AuthorizedKeysCommandUser nobody 337 ''; ··· 343 #!${pkgs.stdenv.shell} 344 ${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \ 345 -repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \ 346 -log-path /tmp/repoguard.log 347 ''; 348 }; ··· 352 after = ["network.target" "sshd.service"]; 353 wantedBy = ["multi-user.target"]; 354 serviceConfig = { 355 - User = "git"; 356 - WorkingDirectory = "/home/git"; 357 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}" 363 ]; 364 - EnvironmentFile = config.services.tangled-knotserver.server.secretFile; 365 ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver"; 366 Restart = "always"; 367 }; 368 }; 369 370 - networking.firewall.allowedTCPPorts = [22]; 371 }; 372 }; 373 ··· 384 virtualisation.cores = 2; 385 services.getty.autologinUser = "root"; 386 environment.systemPackages = with pkgs; [curl vim git]; 387 - systemd.tmpfiles.rules = [ 388 - "w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85" 389 ]; 390 services.tangled-knotserver = { 391 enable = true;
··· 2 description = "atproto github"; 3 4 inputs = { 5 + nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; 6 indigo = { 7 url = "github:oppiliappan/indigo"; 8 flake = false; ··· 15 url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 16 flake = false; 17 }; 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"; 24 flake = false; 25 }; 26 gitignore = { ··· 36 htmx-src, 37 lucide-src, 38 gitignore, 39 + inter-fonts-src, 40 + ibm-plex-mono-src, 41 }: let 42 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 43 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 49 inherit (gitignore.lib) gitignoreSource; 50 in { 51 overlays.default = final: prev: let 52 + goModHash = "sha256-EilWxfqrcKDaSR5zA3ZuDSCq7V+/IfWpKPu8HWhpndA="; 53 buildCmdPackage = name: 54 final.buildGoModule { 55 pname = name; ··· 57 src = gitignoreSource ./.; 58 subPackages = ["cmd/${name}"]; 59 vendorHash = goModHash; 60 + CGO_ENABLED = 0; 61 }; 62 in { 63 indigo-lexgen = final.buildGoModule { ··· 79 mkdir -p appview/pages/static/{fonts,icons} 80 cp -f ${htmx-src} appview/pages/static/htmx.min.js 81 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 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/ 85 ${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 86 popd 87 ''; 88 doCheck = false; 89 subPackages = ["cmd/appview"]; 90 vendorHash = goModHash; 91 + CGO_ENABLED = 1; 92 stdenv = pkgsStatic.stdenv; 93 }; 94 ··· 111 112 runHook postInstall 113 ''; 114 + CGO_ENABLED = 1; 115 }; 116 knotserver-unwrapped = final.pkgsStatic.buildGoModule { 117 pname = "knotserver"; ··· 119 src = gitignoreSource ./.; 120 subPackages = ["cmd/knotserver"]; 121 vendorHash = goModHash; 122 + CGO_ENABLED = 1; 123 }; 124 repoguard = buildCmdPackage "repoguard"; 125 keyfetch = buildCmdPackage "keyfetch"; ··· 159 mkdir -p appview/pages/static/{fonts,icons} 160 cp -f ${htmx-src} appview/pages/static/htmx.min.js 161 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 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/ 165 ''; 166 }; 167 }); ··· 173 ${pkgs.air}/bin/air -c /dev/null \ 174 -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 175 -build.bin "./out/${name}.out" \ 176 + -build.include_ext "go" 177 + ''; 178 + tailwind-watcher = 179 + pkgs.writeShellScriptBin "run" 180 + '' 181 + ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 182 ''; 183 in { 184 watch-appview = { ··· 188 watch-knotserver = { 189 type = "app"; 190 program = ''${air-watcher "knotserver"}/bin/run''; 191 + }; 192 + watch-tailwind = { 193 + type = "app"; 194 + program = ''${tailwind-watcher}/bin/run''; 195 }; 196 }); 197 ··· 246 pkgs, 247 lib, 248 ... 249 + }: let 250 + cfg = config.services.tangled-knotserver; 251 + in 252 with lib; { 253 options = { 254 services.tangled-knotserver = { ··· 268 type = types.str; 269 default = "git"; 270 description = "User that hosts git repos and performs git operations"; 271 + }; 272 + 273 + openFirewall = mkOption { 274 + type = types.bool; 275 + default = true; 276 + description = "Open port 22 in the firewall for ssh"; 277 + }; 278 + 279 + stateDir = mkOption { 280 + type = types.path; 281 + default = "/home/${cfg.gitUser}"; 282 + description = "Tangled knot data directory"; 283 }; 284 285 repo = { 286 scanPath = mkOption { 287 type = types.path; 288 + default = cfg.stateDir; 289 description = "Path where repositories are scanned from"; 290 }; 291 ··· 317 318 dbPath = mkOption { 319 type = types.path; 320 + default = "${cfg.stateDir}/knotserver.db"; 321 description = "Path to the database file"; 322 }; 323 ··· 336 }; 337 }; 338 339 + config = mkIf cfg.enable { 340 environment.systemPackages = with pkgs; [git]; 341 342 system.activationScripts.gitConfig = '' 343 + mkdir -p "${cfg.repo.scanPath}" 344 + chown -R ${cfg.gitUser}:${cfg.gitUser} \ 345 + "${cfg.repo.scanPath}" 346 + 347 + mkdir -p "${cfg.stateDir}/.config/git" 348 + cat > "${cfg.stateDir}/.config/git/config" << EOF 349 [user] 350 name = Git User 351 email = git@example.com 352 EOF 353 + chown -R ${cfg.gitUser}:${cfg.gitUser} \ 354 + "${cfg.stateDir}" 355 ''; 356 357 + users.users.${cfg.gitUser} = { 358 + isSystemUser = true; 359 + useDefaultShell = true; 360 + home = cfg.stateDir; 361 createHome = true; 362 + group = cfg.gitUser; 363 }; 364 365 + users.groups.${cfg.gitUser} = {}; 366 367 services.openssh = { 368 enable = true; 369 extraConfig = '' 370 + Match User ${cfg.gitUser} 371 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 372 AuthorizedKeysCommandUser nobody 373 ''; ··· 379 #!${pkgs.stdenv.shell} 380 ${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \ 381 -repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \ 382 + -internal-api "http://${cfg.server.internalListenAddr}" \ 383 + -git-dir "${cfg.repo.scanPath}" \ 384 -log-path /tmp/repoguard.log 385 ''; 386 }; ··· 390 after = ["network.target" "sshd.service"]; 391 wantedBy = ["multi-user.target"]; 392 serviceConfig = { 393 + User = cfg.gitUser; 394 + WorkingDirectory = cfg.stateDir; 395 Environment = [ 396 + "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 397 + "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 398 + "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 399 + "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 400 + "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 401 + "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 402 + "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 403 ]; 404 + EnvironmentFile = cfg.server.secretFile; 405 ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver"; 406 Restart = "always"; 407 }; 408 }; 409 410 + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22]; 411 }; 412 }; 413 ··· 424 virtualisation.cores = 2; 425 services.getty.autologinUser = "root"; 426 environment.systemPackages = with pkgs; [curl vim git]; 427 + systemd.tmpfiles.rules = let 428 + u = config.services.tangled-knotserver.gitUser; 429 + g = config.services.tangled-knotserver.gitUser; 430 + in [ 431 + "d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first 432 + "f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=5b42390da4c6659f34c9a545adebd8af82c4a19960d735f651e3d582623ba9f2" 433 ]; 434 services.tangled-knotserver = { 435 enable = true;
+3 -3
go.mod
··· 26 github.com/sethvargo/go-envconfig v1.1.0 27 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 28 github.com/yuin/goldmark v1.4.13 29 - golang.org/x/crypto v0.36.0 30 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 31 ) 32 ··· 107 go.uber.org/atomic v1.11.0 // indirect 108 go.uber.org/multierr v1.11.0 // indirect 109 go.uber.org/zap v1.26.0 // indirect 110 - golang.org/x/net v0.37.0 // indirect 111 - golang.org/x/sys v0.31.0 // indirect 112 golang.org/x/time v0.5.0 // indirect 113 google.golang.org/protobuf v1.34.2 // indirect 114 gopkg.in/warnings.v0 v0.1.2 // indirect
··· 26 github.com/sethvargo/go-envconfig v1.1.0 27 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 28 github.com/yuin/goldmark v1.4.13 29 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 30 ) 31 ··· 106 go.uber.org/atomic v1.11.0 // indirect 107 go.uber.org/multierr v1.11.0 // indirect 108 go.uber.org/zap v1.26.0 // indirect 109 + golang.org/x/crypto v0.37.0 // indirect 110 + golang.org/x/net v0.39.0 // indirect 111 + golang.org/x/sys v0.32.0 // indirect 112 golang.org/x/time v0.5.0 // indirect 113 google.golang.org/protobuf v1.34.2 // indirect 114 gopkg.in/warnings.v0 v0.1.2 // indirect
+10 -10
go.sum
··· 303 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 304 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 305 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 306 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 307 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 308 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 309 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 310 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 327 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 328 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 329 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 330 - golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 331 - golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 332 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 333 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 334 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 357 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 361 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 362 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 363 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 364 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 365 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 366 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 367 - golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 368 - golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 369 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 370 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 371 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 372 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 373 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 374 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 375 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 376 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 377 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 378 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 379 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
··· 303 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 304 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 305 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 306 + golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 307 + golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 308 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 309 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 310 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 327 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 328 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 329 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 330 + golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 331 + golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 332 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 333 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 334 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 357 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 + golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 361 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 362 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 363 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 364 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 365 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 366 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 367 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 368 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 369 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 370 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 371 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 372 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 373 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 374 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 375 + golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 376 + golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 377 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 378 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 379 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+191 -91
input.css
··· 3 @tailwind utilities; 4 @layer base { 5 @font-face { 6 - font-family: "iA Writer Quattro S"; 7 - src: url("/static/fonts/iAWriterQuattroS-Regular.ttf") 8 - format("truetype"); 9 font-weight: normal; 10 font-style: normal; 11 font-display: swap; 12 - font-feature-settings: 13 - "calt" 1, 14 - "kern" 1; 15 - } 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 } 26 @font-face { 27 - font-family: "iA Writer Quattro S"; 28 - src: url("/static/fonts/iAWriterQuattroS-Italic.ttf") format("truetype"); 29 font-weight: normal; 30 font-style: italic; 31 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 } 47 48 @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"); 71 font-weight: normal; 72 font-style: italic; 73 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 } 99 100 ::selection { 101 - @apply bg-yellow-400; 102 - @apply text-black; 103 - @apply bg-opacity-30; 104 } 105 106 @layer base { 107 html { 108 - letter-spacing: -0.01em; 109 - word-spacing: -0.07em; 110 - font-size: 14px; 111 } 112 a { 113 - @apply no-underline text-black hover:underline hover:text-gray-800; 114 } 115 116 label { 117 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase; 118 } 119 input { 120 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3; 121 } 122 textarea { 123 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3; 124 } 125 details summary::-webkit-details-marker { 126 display: none; ··· 141 focus-visible:before:outline-4 focus-visible:before:outline-gray-500 142 active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)] 143 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200 144 - disabled:hover:before:bg-white disabled:hover:before:shadow-none; 145 } 146 } 147 @layer utilities { 148 .error { 149 - @apply py-1 text-red-400; 150 } 151 .success { 152 - @apply py-1 text-gray-900; 153 } 154 } 155 }
··· 3 @tailwind utilities; 4 @layer base { 5 @font-face { 6 + font-family: "InterVariable"; 7 + src: url("/static/fonts/InterVariable.woff2") format("woff2"); 8 font-weight: normal; 9 font-style: normal; 10 font-display: swap; 11 } 12 + 13 @font-face { 14 + font-family: "InterVariable"; 15 + src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 font-weight: normal; 17 font-style: italic; 18 font-display: swap; 19 } 20 21 @font-face { 22 + font-family: "IBMPlexMono"; 23 + src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 24 font-weight: normal; 25 font-style: italic; 26 font-display: swap; 27 } 28 29 ::selection { 30 + @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 31 } 32 33 @layer base { 34 html { 35 + font-size: 15px; 36 + } 37 + @supports (font-variation-settings: normal) { 38 + html { 39 + font-feature-settings: 40 + "ss01" 1, 41 + "kern" 1, 42 + "liga" 1, 43 + "cv05" 1, 44 + "tnum" 1; 45 + } 46 } 47 + 48 a { 49 + @apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300; 50 } 51 52 label { 53 + @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 54 } 55 input { 56 + @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 57 } 58 textarea { 59 + @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; 60 } 61 details summary::-webkit-details-marker { 62 display: none; ··· 77 focus-visible:before:outline-4 focus-visible:before:outline-gray-500 78 active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)] 79 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200 80 + disabled:hover:before:bg-white disabled:hover:before:shadow-none 81 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700 82 + dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700 83 + dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748] 84 + dark:focus-visible:before:outline-gray-400 85 + dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)] 86 + dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700; 87 } 88 } 89 @layer utilities { 90 .error { 91 + @apply py-1 text-red-400 dark:text-red-300; 92 } 93 .success { 94 + @apply py-1 text-gray-900 dark:text-gray-100; 95 } 96 } 97 } 98 + 99 + /* Background */ .bg { color: #4c4f69; background-color: #eff1f5; } 100 + /* PreWrapper */ .chroma { color: #4c4f69; background-color: #eff1f5; } 101 + /* Error */ .chroma .err { color: #d20f39 } 102 + /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } 103 + /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 104 + /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 105 + /* LineHighlight */ .chroma .hl { background-color: #bcc0cc } 106 + /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 } 107 + /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8c8fa1 } 108 + /* Line */ .chroma .line { display: flex; } 109 + /* Keyword */ .chroma .k { color: #8839ef } 110 + /* KeywordConstant */ .chroma .kc { color: #fe640b } 111 + /* KeywordDeclaration */ .chroma .kd { color: #d20f39 } 112 + /* KeywordNamespace */ .chroma .kn { color: #179299 } 113 + /* KeywordPseudo */ .chroma .kp { color: #8839ef } 114 + /* KeywordReserved */ .chroma .kr { color: #8839ef } 115 + /* KeywordType */ .chroma .kt { color: #d20f39 } 116 + /* NameAttribute */ .chroma .na { color: #1e66f5 } 117 + /* NameBuiltin */ .chroma .nb { color: #04a5e5 } 118 + /* NameBuiltinPseudo */ .chroma .bp { color: #04a5e5 } 119 + /* NameClass */ .chroma .nc { color: #df8e1d } 120 + /* NameConstant */ .chroma .no { color: #df8e1d } 121 + /* NameDecorator */ .chroma .nd { color: #1e66f5; font-weight: bold } 122 + /* NameEntity */ .chroma .ni { color: #179299 } 123 + /* NameException */ .chroma .ne { color: #fe640b } 124 + /* NameFunction */ .chroma .nf { color: #1e66f5 } 125 + /* NameFunctionMagic */ .chroma .fm { color: #1e66f5 } 126 + /* NameLabel */ .chroma .nl { color: #04a5e5 } 127 + /* NameNamespace */ .chroma .nn { color: #fe640b } 128 + /* NameProperty */ .chroma .py { color: #fe640b } 129 + /* NameTag */ .chroma .nt { color: #8839ef } 130 + /* NameVariable */ .chroma .nv { color: #dc8a78 } 131 + /* NameVariableClass */ .chroma .vc { color: #dc8a78 } 132 + /* NameVariableGlobal */ .chroma .vg { color: #dc8a78 } 133 + /* NameVariableInstance */ .chroma .vi { color: #dc8a78 } 134 + /* NameVariableMagic */ .chroma .vm { color: #dc8a78 } 135 + /* LiteralString */ .chroma .s { color: #40a02b } 136 + /* LiteralStringAffix */ .chroma .sa { color: #d20f39 } 137 + /* LiteralStringBacktick */ .chroma .sb { color: #40a02b } 138 + /* LiteralStringChar */ .chroma .sc { color: #40a02b } 139 + /* LiteralStringDelimiter */ .chroma .dl { color: #1e66f5 } 140 + /* LiteralStringDoc */ .chroma .sd { color: #9ca0b0 } 141 + /* LiteralStringDouble */ .chroma .s2 { color: #40a02b } 142 + /* LiteralStringEscape */ .chroma .se { color: #1e66f5 } 143 + /* LiteralStringHeredoc */ .chroma .sh { color: #9ca0b0 } 144 + /* LiteralStringInterpol */ .chroma .si { color: #40a02b } 145 + /* LiteralStringOther */ .chroma .sx { color: #40a02b } 146 + /* LiteralStringRegex */ .chroma .sr { color: #179299 } 147 + /* LiteralStringSingle */ .chroma .s1 { color: #40a02b } 148 + /* LiteralStringSymbol */ .chroma .ss { color: #40a02b } 149 + /* LiteralNumber */ .chroma .m { color: #fe640b } 150 + /* LiteralNumberBin */ .chroma .mb { color: #fe640b } 151 + /* LiteralNumberFloat */ .chroma .mf { color: #fe640b } 152 + /* LiteralNumberHex */ .chroma .mh { color: #fe640b } 153 + /* LiteralNumberInteger */ .chroma .mi { color: #fe640b } 154 + /* LiteralNumberIntegerLong */ .chroma .il { color: #fe640b } 155 + /* LiteralNumberOct */ .chroma .mo { color: #fe640b } 156 + /* Operator */ .chroma .o { color: #04a5e5; font-weight: bold } 157 + /* OperatorWord */ .chroma .ow { color: #04a5e5; font-weight: bold } 158 + /* Comment */ .chroma .c { color: #9ca0b0; font-style: italic } 159 + /* CommentHashbang */ .chroma .ch { color: #9ca0b0; font-style: italic } 160 + /* CommentMultiline */ .chroma .cm { color: #9ca0b0; font-style: italic } 161 + /* CommentSingle */ .chroma .c1 { color: #9ca0b0; font-style: italic } 162 + /* CommentSpecial */ .chroma .cs { color: #9ca0b0; font-style: italic } 163 + /* CommentPreproc */ .chroma .cp { color: #9ca0b0; font-style: italic } 164 + /* CommentPreprocFile */ .chroma .cpf { color: #9ca0b0; font-weight: bold; font-style: italic } 165 + /* GenericDeleted */ .chroma .gd { color: #d20f39; background-color: oklch(93.6% 0.032 17.717) } 166 + /* GenericEmph */ .chroma .ge { font-style: italic } 167 + /* GenericError */ .chroma .gr { color: #d20f39 } 168 + /* GenericHeading */ .chroma .gh { color: #fe640b; font-weight: bold } 169 + /* GenericInserted */ .chroma .gi { color: #40a02b; background-color: oklch(96.2% 0.044 156.743) } 170 + /* GenericStrong */ .chroma .gs { font-weight: bold } 171 + /* GenericSubheading */ .chroma .gu { color: #fe640b; font-weight: bold } 172 + /* GenericTraceback */ .chroma .gt { color: #d20f39 } 173 + /* GenericUnderline */ .chroma .gl { text-decoration: underline } 174 + 175 + @media (prefers-color-scheme: dark) { 176 + /* Background */ .bg { color: #cad3f5; background-color: #24273a; } 177 + /* PreWrapper */ .chroma { color: #cad3f5; background-color: #24273a; } 178 + /* Error */ .chroma .err { color: #ed8796 } 179 + /* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } 180 + /* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } 181 + /* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } 182 + /* LineHighlight */ .chroma .hl { background-color: #494d64 } 183 + /* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 } 184 + /* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #8087a2 } 185 + /* Line */ .chroma .line { display: flex; } 186 + /* Keyword */ .chroma .k { color: #c6a0f6 } 187 + /* KeywordConstant */ .chroma .kc { color: #f5a97f } 188 + /* KeywordDeclaration */ .chroma .kd { color: #ed8796 } 189 + /* KeywordNamespace */ .chroma .kn { color: #8bd5ca } 190 + /* KeywordPseudo */ .chroma .kp { color: #c6a0f6 } 191 + /* KeywordReserved */ .chroma .kr { color: #c6a0f6 } 192 + /* KeywordType */ .chroma .kt { color: #ed8796 } 193 + /* NameAttribute */ .chroma .na { color: #8aadf4 } 194 + /* NameBuiltin */ .chroma .nb { color: #91d7e3 } 195 + /* NameBuiltinPseudo */ .chroma .bp { color: #91d7e3 } 196 + /* NameClass */ .chroma .nc { color: #eed49f } 197 + /* NameConstant */ .chroma .no { color: #eed49f } 198 + /* NameDecorator */ .chroma .nd { color: #8aadf4; font-weight: bold } 199 + /* NameEntity */ .chroma .ni { color: #8bd5ca } 200 + /* NameException */ .chroma .ne { color: #f5a97f } 201 + /* NameFunction */ .chroma .nf { color: #8aadf4 } 202 + /* NameFunctionMagic */ .chroma .fm { color: #8aadf4 } 203 + /* NameLabel */ .chroma .nl { color: #91d7e3 } 204 + /* NameNamespace */ .chroma .nn { color: #f5a97f } 205 + /* NameProperty */ .chroma .py { color: #f5a97f } 206 + /* NameTag */ .chroma .nt { color: #c6a0f6 } 207 + /* NameVariable */ .chroma .nv { color: #f4dbd6 } 208 + /* NameVariableClass */ .chroma .vc { color: #f4dbd6 } 209 + /* NameVariableGlobal */ .chroma .vg { color: #f4dbd6 } 210 + /* NameVariableInstance */ .chroma .vi { color: #f4dbd6 } 211 + /* NameVariableMagic */ .chroma .vm { color: #f4dbd6 } 212 + /* LiteralString */ .chroma .s { color: #a6da95 } 213 + /* LiteralStringAffix */ .chroma .sa { color: #ed8796 } 214 + /* LiteralStringBacktick */ .chroma .sb { color: #a6da95 } 215 + /* LiteralStringChar */ .chroma .sc { color: #a6da95 } 216 + /* LiteralStringDelimiter */ .chroma .dl { color: #8aadf4 } 217 + /* LiteralStringDoc */ .chroma .sd { color: #6e738d } 218 + /* LiteralStringDouble */ .chroma .s2 { color: #a6da95 } 219 + /* LiteralStringEscape */ .chroma .se { color: #8aadf4 } 220 + /* LiteralStringHeredoc */ .chroma .sh { color: #6e738d } 221 + /* LiteralStringInterpol */ .chroma .si { color: #a6da95 } 222 + /* LiteralStringOther */ .chroma .sx { color: #a6da95 } 223 + /* LiteralStringRegex */ .chroma .sr { color: #8bd5ca } 224 + /* LiteralStringSingle */ .chroma .s1 { color: #a6da95 } 225 + /* LiteralStringSymbol */ .chroma .ss { color: #a6da95 } 226 + /* LiteralNumber */ .chroma .m { color: #f5a97f } 227 + /* LiteralNumberBin */ .chroma .mb { color: #f5a97f } 228 + /* LiteralNumberFloat */ .chroma .mf { color: #f5a97f } 229 + /* LiteralNumberHex */ .chroma .mh { color: #f5a97f } 230 + /* LiteralNumberInteger */ .chroma .mi { color: #f5a97f } 231 + /* LiteralNumberIntegerLong */ .chroma .il { color: #f5a97f } 232 + /* LiteralNumberOct */ .chroma .mo { color: #f5a97f } 233 + /* Operator */ .chroma .o { color: #91d7e3; font-weight: bold } 234 + /* OperatorWord */ .chroma .ow { color: #91d7e3; font-weight: bold } 235 + /* Comment */ .chroma .c { color: #6e738d; font-style: italic } 236 + /* CommentHashbang */ .chroma .ch { color: #6e738d; font-style: italic } 237 + /* CommentMultiline */ .chroma .cm { color: #6e738d; font-style: italic } 238 + /* CommentSingle */ .chroma .c1 { color: #6e738d; font-style: italic } 239 + /* CommentSpecial */ .chroma .cs { color: #6e738d; font-style: italic } 240 + /* CommentPreproc */ .chroma .cp { color: #6e738d; font-style: italic } 241 + /* CommentPreprocFile */ .chroma .cpf { color: #6e738d; font-weight: bold; font-style: italic } 242 + /* GenericDeleted */ .chroma .gd { color: #ed8796; background-color: oklch(44.4% 0.177 26.899 / 0.5) } 243 + /* GenericEmph */ .chroma .ge { font-style: italic } 244 + /* GenericError */ .chroma .gr { color: #ed8796 } 245 + /* GenericHeading */ .chroma .gh { color: #f5a97f; font-weight: bold } 246 + /* GenericInserted */ .chroma .gi { color: #a6da95; background-color: oklch(44.8% 0.119 151.328 / 0.5) } 247 + /* GenericStrong */ .chroma .gs { font-weight: bold } 248 + /* GenericSubheading */ .chroma .gu { color: #f5a97f; font-weight: bold } 249 + /* GenericTraceback */ .chroma .gt { color: #ed8796 } 250 + /* GenericUnderline */ .chroma .gl { text-decoration: underline } 251 + } 252 + 253 + .chroma .line:has(.ln:target) { 254 + @apply bg-amber-400/30 dark:bg-amber-500/20 255 + }
+91 -24
jetstream/jetstream.go
··· 4 "context" 5 "fmt" 6 "log/slog" 7 "sync" 8 "time" 9 10 "github.com/bluesky-social/jetstream/pkg/client" ··· 16 type DB interface { 17 GetLastTimeUs() (int64, error) 18 SaveLastTimeUs(int64) error 19 - UpdateLastTimeUs(int64) error 20 } 21 22 type JetstreamClient struct { 23 cfg *client.ClientConfig 24 client *client.Client 25 ident string 26 l *slog.Logger 27 28 db DB 29 waitForDid bool 30 mu sync.RWMutex ··· 37 if did == "" { 38 return 39 } 40 j.mu.Lock() 41 - j.cfg.WantedDids = append(j.cfg.WantedDids, did) 42 j.mu.Unlock() 43 } 44 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() 53 54 - j.cancelMu.Lock() 55 - if j.cancel != nil { 56 - j.cancel() 57 } 58 - j.cancelMu.Unlock() 59 } 60 61 func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) { ··· 66 } 67 68 return &JetstreamClient{ 69 - cfg: cfg, 70 - ident: ident, 71 - db: db, 72 - l: logger, 73 74 // This will make the goroutine in StartJetstream wait until 75 - // cfg.WantedDids has been populated, typically using UpdateDids. 76 waitForDid: waitForDid, 77 }, nil 78 } 79 80 // 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). 82 func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { 83 logger := j.l 84 85 - sched := sequential.NewScheduler(j.ident, logger, processFunc) 86 87 client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 88 if err != nil { ··· 92 93 go func() { 94 if j.waitForDid { 95 - for len(j.cfg.WantedDids) == 0 { 96 time.Sleep(time.Second) 97 } 98 } 99 logger.Info("done waiting for did") 100 j.connectAndRead(ctx) 101 }() 102 ··· 130 } 131 } 132 133 func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 { 134 l := log.FromContext(ctx) 135 lastTimeUs, err := j.db.GetLastTimeUs() ··· 142 } 143 } 144 145 - // If last time is older than a week, start from now 146 if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 { 147 lastTimeUs = time.Now().UnixMicro() 148 l.Warn("last time us is older than 2 days; discarding that and starting from now") 149 - err = j.db.UpdateLastTimeUs(lastTimeUs) 150 if err != nil { 151 l.Error("failed to save last time us", "error", err) 152 } ··· 155 l.Info("found last time_us", "time_us", lastTimeUs) 156 return &lastTimeUs 157 }
··· 4 "context" 5 "fmt" 6 "log/slog" 7 + "os" 8 + "os/signal" 9 "sync" 10 + "syscall" 11 "time" 12 13 "github.com/bluesky-social/jetstream/pkg/client" ··· 19 type DB interface { 20 GetLastTimeUs() (int64, error) 21 SaveLastTimeUs(int64) error 22 } 23 24 + type Set[T comparable] map[T]struct{} 25 + 26 type JetstreamClient struct { 27 cfg *client.ClientConfig 28 client *client.Client 29 ident string 30 l *slog.Logger 31 32 + wantedDids Set[string] 33 db DB 34 waitForDid bool 35 mu sync.RWMutex ··· 42 if did == "" { 43 return 44 } 45 + 46 + j.l.Info("adding did to in-memory filter", "did", did) 47 j.mu.Lock() 48 + j.wantedDids[did] = struct{}{} 49 j.mu.Unlock() 50 } 51 52 + type processor func(context.Context, *models.Event) error 53 54 + func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 55 + // empty filter => all dids allowed 56 + if len(j.wantedDids) == 0 { 57 + return processFunc 58 } 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 + } 68 } 69 70 func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) { ··· 75 } 76 77 return &JetstreamClient{ 78 + cfg: cfg, 79 + ident: ident, 80 + db: db, 81 + l: logger, 82 + wantedDids: make(map[string]struct{}), 83 84 // This will make the goroutine in StartJetstream wait until 85 + // j.wantedDids has been populated, typically using addDids. 86 waitForDid: waitForDid, 87 }, nil 88 } 89 90 // StartJetstream starts the jetstream client and processes events using the provided processFunc. 91 + // The caller is responsible for saving the last time_us to the database (just use your db.UpdateLastTimeUs). 92 func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { 93 logger := j.l 94 95 + sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 96 97 client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 98 if err != nil { ··· 102 103 go func() { 104 if j.waitForDid { 105 + for len(j.wantedDids) == 0 { 106 time.Sleep(time.Second) 107 } 108 } 109 logger.Info("done waiting for did") 110 + 111 + go j.periodicLastTimeSave(ctx) 112 + j.saveIfKilled(ctx) 113 + 114 j.connectAndRead(ctx) 115 }() 116 ··· 144 } 145 } 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 + 162 func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 { 163 l := log.FromContext(ctx) 164 lastTimeUs, err := j.db.GetLastTimeUs() ··· 171 } 172 } 173 174 + // If last time is older than 2 days, start from now 175 if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 { 176 lastTimeUs = time.Now().UnixMicro() 177 l.Warn("last time us is older than 2 days; discarding that and starting from now") 178 + err = j.db.SaveLastTimeUs(lastTimeUs) 179 if err != nil { 180 l.Error("failed to save last time us", "error", err) 181 } ··· 184 l.Info("found last time_us", "time_us", lastTimeUs) 185 return &lastTimeUs 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
··· 1 package db 2 3 func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 4 - _, err := d.db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs) 5 return err 6 } 7 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 func (d *DB) GetLastTimeUs() (int64, error) { 17 var lastTimeUs int64 18 - row := d.db.QueryRow(`select last_time_us from _jetstream`) 19 err := row.Scan(&lastTimeUs) 20 return lastTimeUs, err 21 }
··· 1 package db 2 3 func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 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) 9 return err 10 } 11 12 func (d *DB) GetLastTimeUs() (int64, error) { 13 var lastTimeUs int64 14 + row := d.db.QueryRow(`select last_time_us from _jetstream where id = 1;`) 15 err := row.Scan(&lastTimeUs) 16 return lastTimeUs, err 17 }
+2 -2
knotserver/db/pubkeys.go
··· 44 return err 45 } 46 47 - func (pk *PublicKey) JSON() map[string]interface{} { 48 - return map[string]interface{}{ 49 "did": pk.Did, 50 "key": pk.Key, 51 "created": pk.Created,
··· 44 return err 45 } 46 47 + func (pk *PublicKey) JSON() map[string]any { 48 + return map[string]any{ 49 "did": pk.Did, 50 "key": pk.Key, 51 "created": pk.Created,
+112 -10
knotserver/git/diff.go
··· 1 package git 2 3 import ( 4 "fmt" 5 "log" 6 "strings" 7 8 "github.com/bluekeyes/go-gitdiff/gitdiff" 9 "github.com/go-git/go-git/v5/plumbing/object" 10 "tangled.sh/tangled.sh/core/types" 11 ) 12 ··· 46 } 47 48 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 for _, d := range diffs { 60 ndiff := types.Diff{} 61 ndiff.Name.New = d.NewName ··· 82 } 83 84 nd.Stat.FilesChanged = len(diffs) 85 86 return &nd, nil 87 }
··· 1 package git 2 3 import ( 4 + "bytes" 5 "fmt" 6 "log" 7 + "os" 8 + "os/exec" 9 "strings" 10 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 12 + "github.com/go-git/go-git/v5/plumbing" 13 "github.com/go-git/go-git/v5/plumbing/object" 14 + "tangled.sh/tangled.sh/core/patchutil" 15 "tangled.sh/tangled.sh/core/types" 16 ) 17 ··· 51 } 52 53 nd := types.NiceDiff{} 54 for _, d := range diffs { 55 ndiff := types.Diff{} 56 ndiff.Name.New = d.NewName ··· 77 } 78 79 nd.Stat.FilesChanged = len(diffs) 80 + nd.Commit.This = c.Hash.String() 81 + 82 + if parent.Hash.IsZero() { 83 + nd.Commit.Parent = "" 84 + } else { 85 + nd.Commit.Parent = parent.Hash.String() 86 + } 87 + nd.Commit.Author = c.Author 88 + nd.Commit.Message = c.Message 89 90 return &nd, nil 91 } 92 + 93 + func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) { 94 + tree1, err := commit1.Tree() 95 + if err != nil { 96 + return nil, err 97 + } 98 + 99 + tree2, err := commit2.Tree() 100 + if err != nil { 101 + return nil, err 102 + } 103 + 104 + diff, err := object.DiffTree(tree1, tree2) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + patch, err := diff.Patch() 110 + if err != nil { 111 + return nil, err 112 + } 113 + 114 + diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String())) 115 + if err != nil { 116 + return nil, err 117 + } 118 + 119 + return &types.DiffTree{ 120 + Rev1: commit1.Hash.String(), 121 + Rev2: commit2.Hash.String(), 122 + Patch: patch.String(), 123 + Diff: diffs, 124 + }, nil 125 + } 126 + 127 + // FormatPatch generates a git-format-patch output between two commits, 128 + // and returns the raw format-patch series, a parsed FormatPatch and an error. 129 + func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) { 130 + var stdout bytes.Buffer 131 + cmd := exec.Command( 132 + "git", 133 + "-C", 134 + g.path, 135 + "format-patch", 136 + fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()), 137 + "--stdout", 138 + ) 139 + cmd.Stdout = &stdout 140 + cmd.Stderr = os.Stderr 141 + err := cmd.Run() 142 + if err != nil { 143 + return "", nil, err 144 + } 145 + 146 + formatPatch, err := patchutil.ExtractPatches(stdout.String()) 147 + if err != nil { 148 + return "", nil, err 149 + } 150 + 151 + return stdout.String(), formatPatch, nil 152 + } 153 + 154 + func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) { 155 + isAncestor, err := commit1.IsAncestor(commit2) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + if isAncestor { 161 + return commit1, nil 162 + } 163 + 164 + mergeBase, err := commit1.MergeBase(commit2) 165 + if err != nil { 166 + return nil, err 167 + } 168 + 169 + if len(mergeBase) == 0 { 170 + return nil, fmt.Errorf("failed to find a merge-base") 171 + } 172 + 173 + return mergeBase[0], nil 174 + } 175 + 176 + func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) { 177 + rev, err := g.r.ResolveRevision(plumbing.Revision(revStr)) 178 + if err != nil { 179 + return nil, fmt.Errorf("resolving revision %s: %w", revStr, err) 180 + } 181 + 182 + commit, err := g.r.CommitObject(*rev) 183 + if err != nil { 184 + 185 + return nil, fmt.Errorf("getting commit for %s: %w", revStr, err) 186 + } 187 + 188 + return commit, nil 189 + }
+50
knotserver/git/fork.go
···
··· 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
··· 131 return &g, nil 132 } 133 134 func (g *GitRepo) Commits() ([]*object.Commit, error) { 135 ci, err := g.r.Log(&git.LogOptions{From: g.h}) 136 if err != nil { ··· 228 return branches, nil 229 } 230 231 func (g *GitRepo) FindMainBranch() (string, error) { 232 ref, err := g.r.Head() 233 if err != nil { ··· 308 } 309 cacheMu.RUnlock() 310 311 - cmd := exec.Command("git", "-C", g.path, "log", "-1", "--format=%H %ct", "--", path) 312 313 var out bytes.Buffer 314 cmd.Stdout = &out
··· 131 return &g, nil 132 } 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 + 144 func (g *GitRepo) Commits() ([]*object.Commit, error) { 145 ci, err := g.r.Log(&git.LogOptions{From: g.h}) 146 if err != nil { ··· 238 return branches, nil 239 } 240 241 + func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 242 + ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 243 + if err != nil { 244 + return nil, fmt.Errorf("branch: %w", err) 245 + } 246 + 247 + if !ref.Name().IsBranch() { 248 + return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 249 + } 250 + 251 + return ref, nil 252 + } 253 + 254 + func (g *GitRepo) SetDefaultBranch(branch string) error { 255 + ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch)) 256 + return g.r.Storer.SetReference(ref) 257 + } 258 + 259 func (g *GitRepo) FindMainBranch() (string, error) { 260 ref, err := g.r.Head() 261 if err != nil { ··· 336 } 337 cacheMu.RUnlock() 338 339 + cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path) 340 341 var out bytes.Buffer 342 cmd.Stdout = &out
+17 -2
knotserver/git/merge.go
··· 10 11 "github.com/go-git/go-git/v5" 12 "github.com/go-git/go-git/v5/plumbing" 13 ) 14 15 type ErrMerge struct { ··· 30 CommitBody string 31 AuthorName string 32 AuthorEmail string 33 } 34 35 func (e ErrMerge) Error() string { ··· 89 if checkOnly { 90 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 91 } else { 92 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 93 94 if opts != nil { 95 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 96 applyCmd.Stderr = &stderr ··· 153 } 154 155 func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 156 patchFile, err := g.createTempFileWithPatch(patchData) 157 if err != nil { 158 return &ErrMerge{ ··· 171 } 172 defer os.RemoveAll(tmpDir) 173 174 - return g.applyPatch(tmpDir, patchFile, true, nil) 175 } 176 177 func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
··· 10 11 "github.com/go-git/go-git/v5" 12 "github.com/go-git/go-git/v5/plumbing" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 ) 15 16 type ErrMerge struct { ··· 31 CommitBody string 32 AuthorName string 33 AuthorEmail string 34 + FormatPatch bool 35 } 36 37 func (e ErrMerge) Error() string { ··· 91 if checkOnly { 92 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 93 } else { 94 + // if patch is a format-patch, apply using 'git am' 95 + if opts.FormatPatch { 96 + amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 97 + amCmd.Stderr = &stderr 98 + if err := amCmd.Run(); err != nil { 99 + return fmt.Errorf("patch application failed: %s", stderr.String()) 100 + } 101 + return nil 102 + } 103 104 + // else, apply using 'git apply' and commit it manually 105 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 106 if opts != nil { 107 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 108 applyCmd.Stderr = &stderr ··· 165 } 166 167 func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 168 + var opts MergeOptions 169 + opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 170 + 171 patchFile, err := g.createTempFileWithPatch(patchData) 172 if err != nil { 173 return &ErrMerge{ ··· 186 } 187 defer os.RemoveAll(tmpDir) 188 189 + return g.applyPatch(tmpDir, patchFile, true, &opts) 190 } 191 192 func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+31 -24
knotserver/git/service/service.go
··· 8 "net/http" 9 "os/exec" 10 "strings" 11 "syscall" 12 ) 13 ··· 68 } 69 70 func (c *ServiceCommand) UploadPack() error { 71 - cmd := exec.Command("git", []string{ 72 - "-c", "uploadpack.allowFilter=true", 73 - "upload-pack", 74 - "--stateless-rpc", 75 - ".", 76 - }...) 77 cmd.Dir = c.Dir 78 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 79 80 - stdoutPipe, _ := cmd.StdoutPipe() 81 - cmd.Stderr = cmd.Stdout 82 - defer stdoutPipe.Close() 83 84 stdinPipe, err := cmd.StdinPipe() 85 if err != nil { 86 - return err 87 } 88 - defer stdinPipe.Close() 89 90 if err := cmd.Start(); err != nil { 91 - log.Printf("git: failed to start git-upload-pack: %s", err) 92 - return err 93 } 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() 100 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 if err := cmd.Wait(); err != nil { 106 - log.Printf("git: failed to wait for git-upload-pack: %s", err) 107 - return err 108 } 109 110 return nil
··· 8 "net/http" 9 "os/exec" 10 "strings" 11 + "sync" 12 "syscall" 13 ) 14 ··· 69 } 70 71 func (c *ServiceCommand) UploadPack() error { 72 + var stderr bytes.Buffer 73 + 74 + cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true", 75 + "upload-pack", "--stateless-rpc", ".") 76 cmd.Dir = c.Dir 77 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 78 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 85 86 stdinPipe, err := cmd.StdinPipe() 87 if err != nil { 88 + return fmt.Errorf("failed to create stdin pipe: %w", err) 89 } 90 91 if err := cmd.Start(); err != nil { 92 + return fmt.Errorf("failed to start git-upload-pack: %w", err) 93 } 94 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() 112 113 if err := cmd.Wait(); err != nil { 114 + return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String()) 115 } 116 117 return nil
+8 -1
knotserver/git/tree.go
··· 2 3 import ( 4 "fmt" 5 6 "github.com/go-git/go-git/v5/plumbing/object" 7 "tangled.sh/tangled.sh/core/types" ··· 56 lastCommit, err := g.LastCommitForPath(fpath) 57 if err != nil { 58 fmt.Println("error getting last commit time:", err) 59 - continue 60 } 61 62 nts = append(nts, types.NiceTree{
··· 2 3 import ( 4 "fmt" 5 + "time" 6 7 "github.com/go-git/go-git/v5/plumbing/object" 8 "tangled.sh/tangled.sh/core/types" ··· 57 lastCommit, err := g.LastCommitForPath(fpath) 58 if err != nil { 59 fmt.Println("error getting last commit time:", err) 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 + } 67 } 68 69 nts = append(nts, types.NiceTree{
+22 -17
knotserver/git.go
··· 34 func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 35 did := chi.URLParam(r, "did") 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, 47 } 48 49 - var reader io.ReadCloser 50 - reader = r.Body 51 - 52 if r.Header.Get("Content-Encoding") == "gzip" { 53 - reader, err := gzip.NewReader(r.Body) 54 if err != nil { 55 writeError(w, err.Error(), 500) 56 d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 57 return 58 } 59 - defer reader.Close() 60 } 61 62 - cmd.Stdin = reader 63 if err := cmd.UploadPack(); err != nil { 64 - writeError(w, err.Error(), 500) 65 d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 66 return 67 }
··· 34 func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 35 did := chi.URLParam(r, "did") 36 name := chi.URLParam(r, "name") 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 42 } 43 44 + var bodyReader io.ReadCloser = r.Body 45 if r.Header.Get("Content-Encoding") == "gzip" { 46 + gzipReader, err := gzip.NewReader(r.Body) 47 if err != nil { 48 writeError(w, err.Error(), 500) 49 d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 50 return 51 } 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, 65 } 66 67 + w.WriteHeader(http.StatusOK) 68 + 69 if err := cmd.UploadPack(); err != nil { 70 d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 71 return 72 }
+47 -2
knotserver/handler.go
··· 5 "fmt" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 "tangled.sh/tangled.sh/core/jetstream" ··· 59 if err != nil { 60 return nil, fmt.Errorf("failed to get all Dids: %w", err) 61 } 62 if len(dids) > 0 { 63 h.knotInitialized = true 64 close(h.init) 65 - // h.jc.UpdateDids(dids) 66 } 67 68 r.Get("/", h.Index) 69 r.Route("/{did}", func(r chi.Router) { 70 // Repo routes 71 r.Route("/{name}", func(r chi.Router) { ··· 77 r.Get("/", h.RepoIndex) 78 r.Get("/info/refs", h.InfoRefs) 79 r.Post("/git-upload-pack", h.UploadPack) 80 81 r.Route("/merge", func(r chi.Router) { 82 r.With(h.VerifySignature) ··· 97 r.Get("/archive/{file}", h.Archive) 98 r.Get("/commit/{ref}", h.Diff) 99 r.Get("/tags", h.Tags) 100 - r.Get("/branches", h.Branches) 101 }) 102 }) 103 ··· 106 r.Use(h.VerifySignature) 107 r.Put("/new", h.NewRepo) 108 r.Delete("/", h.RemoveRepo) 109 }) 110 111 r.Route("/member", func(r chi.Router) { ··· 124 125 return r, nil 126 }
··· 5 "fmt" 6 "log/slog" 7 "net/http" 8 + "runtime/debug" 9 10 "github.com/go-chi/chi/v5" 11 "tangled.sh/tangled.sh/core/jetstream" ··· 60 if err != nil { 61 return nil, fmt.Errorf("failed to get all Dids: %w", err) 62 } 63 + 64 if len(dids) > 0 { 65 h.knotInitialized = true 66 close(h.init) 67 + for _, d := range dids { 68 + h.jc.AddDid(d) 69 + } 70 } 71 72 r.Get("/", h.Index) 73 + r.Get("/capabilities", h.Capabilities) 74 + r.Get("/version", h.Version) 75 r.Route("/{did}", func(r chi.Router) { 76 // Repo routes 77 r.Route("/{name}", func(r chi.Router) { ··· 83 r.Get("/", h.RepoIndex) 84 r.Get("/info/refs", h.InfoRefs) 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) 89 90 r.Route("/merge", func(r chi.Router) { 91 r.With(h.VerifySignature) ··· 106 r.Get("/archive/{file}", h.Archive) 107 r.Get("/commit/{ref}", h.Diff) 108 r.Get("/tags", h.Tags) 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 + }) 117 }) 118 }) 119 ··· 122 r.Use(h.VerifySignature) 123 r.Put("/new", h.NewRepo) 124 r.Delete("/", h.RemoveRepo) 125 + r.Post("/fork", h.RepoFork) 126 }) 127 128 r.Route("/member", func(r chi.Router) { ··· 141 142 return r, nil 143 } 144 + 145 + // version is set during build time. 146 + var version string 147 + 148 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 149 + if version == "" { 150 + info, ok := debug.ReadBuildInfo() 151 + if !ok { 152 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 153 + return 154 + } 155 + 156 + var modVer string 157 + for _, mod := range info.Deps { 158 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 159 + version = mod.Version 160 + break 161 + } 162 + } 163 + 164 + if modVer == "" { 165 + version = "unknown" 166 + } 167 + } 168 + 169 + w.Header().Set("Content-Type", "text/plain") 170 + fmt.Fprintf(w, "knotserver/%s", version) 171 + }
+2 -2
knotserver/jetstream.go
··· 53 l.Error("failed to add did", "error", err) 54 return fmt.Errorf("failed to add did: %w", err) 55 } 56 57 if err := h.fetchAndAddKeys(ctx, did); err != nil { 58 return fmt.Errorf("failed to fetch and add keys: %w", err) ··· 115 eventTime := event.TimeUS 116 lastTimeUs := eventTime + 1 117 fmt.Println("lastTimeUs", lastTimeUs) 118 - if err := h.db.UpdateLastTimeUs(lastTimeUs); err != nil { 119 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 120 } 121 - // h.jc.UpdateDids([]string{did}) 122 }() 123 124 raw := json.RawMessage(event.Commit.Record)
··· 53 l.Error("failed to add did", "error", err) 54 return fmt.Errorf("failed to add did: %w", err) 55 } 56 + h.jc.AddDid(did) 57 58 if err := h.fetchAndAddKeys(ctx, did); err != nil { 59 return fmt.Errorf("failed to fetch and add keys: %w", err) ··· 116 eventTime := event.TimeUS 117 lastTimeUs := eventTime + 1 118 fmt.Println("lastTimeUs", lastTimeUs) 119 + if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 120 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 121 } 122 }() 123 124 raw := json.RawMessage(event.Commit.Record)
+237 -3
knotserver/routes.go
··· 24 "github.com/go-git/go-git/v5/plumbing/object" 25 "tangled.sh/tangled.sh/core/knotserver/db" 26 "tangled.sh/tangled.sh/core/knotserver/git" 27 "tangled.sh/tangled.sh/core/types" 28 ) 29 30 func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 31 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 32 } 33 34 func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { ··· 436 return 437 } 438 439 func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 440 l := h.l.With("handler", "Keys") 441 ··· 448 return 449 } 450 451 - data := make([]map[string]interface{}, 0) 452 for _, key := range keys { 453 j := key.JSON() 454 data = append(data, j) ··· 526 w.WriteHeader(http.StatusNoContent) 527 } 528 529 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 530 l := h.l.With("handler", "RemoveRepo") 531 ··· 585 notFound(w) 586 return 587 } 588 if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 589 var mergeErr *git.ErrMerge 590 if errors.As(err, &mergeErr) { ··· 665 h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 666 } 667 668 func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 669 l := h.l.With("handler", "AddMember") 670 ··· 684 writeError(w, err.Error(), http.StatusInternalServerError) 685 return 686 } 687 - 688 h.jc.AddDid(did) 689 if err := h.e.AddMember(ThisServer, did); err != nil { 690 l.Error("adding member", "error", err.Error()) 691 writeError(w, err.Error(), http.StatusInternalServerError) ··· 739 w.WriteHeader(http.StatusNoContent) 740 } 741 742 func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 743 l := h.l.With("handler", "Init") 744 ··· 768 writeError(w, err.Error(), http.StatusInternalServerError) 769 return 770 } 771 772 - // h.jc.UpdateDids([]string{data.Did}) 773 if err := h.e.AddOwner(ThisServer, data.Did); err != nil { 774 l.Error("adding owner", "error", err.Error()) 775 writeError(w, err.Error(), http.StatusInternalServerError)
··· 24 "github.com/go-git/go-git/v5/plumbing/object" 25 "tangled.sh/tangled.sh/core/knotserver/db" 26 "tangled.sh/tangled.sh/core/knotserver/git" 27 + "tangled.sh/tangled.sh/core/patchutil" 28 "tangled.sh/tangled.sh/core/types" 29 ) 30 31 func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 32 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 33 + } 34 + 35 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 36 + w.Header().Set("Content-Type", "application/json") 37 + 38 + capabilities := map[string]any{ 39 + "pull_requests": map[string]any{ 40 + "format_patch": true, 41 + "patch_submissions": true, 42 + "branch_submissions": true, 43 + "fork_submissions": true, 44 + }, 45 + } 46 + 47 + jsonData, err := json.Marshal(capabilities) 48 + if err != nil { 49 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 50 + return 51 + } 52 + 53 + w.Write(jsonData) 54 } 55 56 func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { ··· 458 return 459 } 460 461 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 462 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 463 + branchName := chi.URLParam(r, "branch") 464 + l := h.l.With("handler", "Branch") 465 + 466 + gr, err := git.PlainOpen(path) 467 + if err != nil { 468 + notFound(w) 469 + return 470 + } 471 + 472 + ref, err := gr.Branch(branchName) 473 + if err != nil { 474 + l.Error("getting branches", "error", err.Error()) 475 + writeError(w, err.Error(), http.StatusInternalServerError) 476 + return 477 + } 478 + 479 + resp := types.RepoBranchResponse{ 480 + Branch: types.Branch{ 481 + Reference: types.Reference{ 482 + Name: ref.Name().Short(), 483 + Hash: ref.Hash().String(), 484 + }, 485 + }, 486 + } 487 + 488 + writeJSON(w, resp) 489 + return 490 + } 491 + 492 func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 493 l := h.l.With("handler", "Keys") 494 ··· 501 return 502 } 503 504 + data := make([]map[string]any, 0) 505 for _, key := range keys { 506 j := key.JSON() 507 data = append(data, j) ··· 579 w.WriteHeader(http.StatusNoContent) 580 } 581 582 + func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 583 + l := h.l.With("handler", "RepoFork") 584 + 585 + data := struct { 586 + Did string `json:"did"` 587 + Source string `json:"source"` 588 + Name string `json:"name,omitempty"` 589 + }{} 590 + 591 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 592 + writeError(w, "invalid request body", http.StatusBadRequest) 593 + return 594 + } 595 + 596 + did := data.Did 597 + source := data.Source 598 + 599 + if did == "" || source == "" { 600 + l.Error("invalid request body, empty did or name") 601 + w.WriteHeader(http.StatusBadRequest) 602 + return 603 + } 604 + 605 + var name string 606 + if data.Name != "" { 607 + name = data.Name 608 + } else { 609 + name = filepath.Base(source) 610 + } 611 + 612 + relativeRepoPath := filepath.Join(did, name) 613 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 614 + 615 + err := git.Fork(repoPath, source) 616 + if err != nil { 617 + l.Error("forking repo", "error", err.Error()) 618 + writeError(w, err.Error(), http.StatusInternalServerError) 619 + return 620 + } 621 + 622 + // add perms for this user to access the repo 623 + err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 624 + if err != nil { 625 + l.Error("adding repo permissions", "error", err.Error()) 626 + writeError(w, err.Error(), http.StatusInternalServerError) 627 + return 628 + } 629 + 630 + w.WriteHeader(http.StatusNoContent) 631 + } 632 + 633 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 634 l := h.l.With("handler", "RemoveRepo") 635 ··· 689 notFound(w) 690 return 691 } 692 + 693 + mo.FormatPatch = patchutil.IsFormatPatch(patch) 694 + 695 if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 696 var mergeErr *git.ErrMerge 697 if errors.As(err, &mergeErr) { ··· 772 h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 773 } 774 775 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 776 + rev1 := chi.URLParam(r, "rev1") 777 + rev1, _ = url.PathUnescape(rev1) 778 + 779 + rev2 := chi.URLParam(r, "rev2") 780 + rev2, _ = url.PathUnescape(rev2) 781 + 782 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 783 + 784 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 785 + gr, err := git.PlainOpen(path) 786 + if err != nil { 787 + notFound(w) 788 + return 789 + } 790 + 791 + commit1, err := gr.ResolveRevision(rev1) 792 + if err != nil { 793 + l.Error("error resolving revision 1", "msg", err.Error()) 794 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 795 + return 796 + } 797 + 798 + commit2, err := gr.ResolveRevision(rev2) 799 + if err != nil { 800 + l.Error("error resolving revision 2", "msg", err.Error()) 801 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 802 + return 803 + } 804 + 805 + mergeBase, err := gr.MergeBase(commit1, commit2) 806 + if err != nil { 807 + l.Error("failed to find merge-base", "msg", err.Error()) 808 + writeError(w, "failed to calculate diff", http.StatusBadRequest) 809 + return 810 + } 811 + 812 + rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2) 813 + if err != nil { 814 + l.Error("error comparing revisions", "msg", err.Error()) 815 + writeError(w, "error comparing revisions", http.StatusBadRequest) 816 + return 817 + } 818 + 819 + writeJSON(w, types.RepoFormatPatchResponse{ 820 + Rev1: commit1.Hash.String(), 821 + Rev2: commit2.Hash.String(), 822 + FormatPatch: formatPatch, 823 + Patch: rawPatch, 824 + }) 825 + return 826 + } 827 + 828 + func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 829 + l := h.l.With("handler", "NewHiddenRef") 830 + 831 + forkRef := chi.URLParam(r, "forkRef") 832 + remoteRef := chi.URLParam(r, "remoteRef") 833 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 834 + gr, err := git.PlainOpen(path) 835 + if err != nil { 836 + notFound(w) 837 + return 838 + } 839 + 840 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 841 + if err != nil { 842 + l.Error("error tracking hidden remote ref", "msg", err.Error()) 843 + writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 844 + return 845 + } 846 + 847 + w.WriteHeader(http.StatusNoContent) 848 + return 849 + } 850 + 851 func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 852 l := h.l.With("handler", "AddMember") 853 ··· 867 writeError(w, err.Error(), http.StatusInternalServerError) 868 return 869 } 870 h.jc.AddDid(did) 871 + 872 if err := h.e.AddMember(ThisServer, did); err != nil { 873 l.Error("adding member", "error", err.Error()) 874 writeError(w, err.Error(), http.StatusInternalServerError) ··· 922 w.WriteHeader(http.StatusNoContent) 923 } 924 925 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 926 + l := h.l.With("handler", "DefaultBranch") 927 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 928 + 929 + gr, err := git.Open(path, "") 930 + if err != nil { 931 + notFound(w) 932 + return 933 + } 934 + 935 + branch, err := gr.FindMainBranch() 936 + if err != nil { 937 + writeError(w, err.Error(), http.StatusInternalServerError) 938 + l.Error("getting default branch", "error", err.Error()) 939 + return 940 + } 941 + 942 + writeJSON(w, types.RepoDefaultBranchResponse{ 943 + Branch: branch, 944 + }) 945 + } 946 + 947 + func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 948 + l := h.l.With("handler", "SetDefaultBranch") 949 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 950 + 951 + data := struct { 952 + Branch string `json:"branch"` 953 + }{} 954 + 955 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 956 + writeError(w, err.Error(), http.StatusBadRequest) 957 + return 958 + } 959 + 960 + gr, err := git.Open(path, "") 961 + if err != nil { 962 + notFound(w) 963 + return 964 + } 965 + 966 + err = gr.SetDefaultBranch(data.Branch) 967 + if err != nil { 968 + writeError(w, err.Error(), http.StatusInternalServerError) 969 + l.Error("setting default branch", "error", err.Error()) 970 + return 971 + } 972 + 973 + w.WriteHeader(http.StatusNoContent) 974 + } 975 + 976 func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 977 l := h.l.With("handler", "Init") 978 ··· 1002 writeError(w, err.Error(), http.StatusInternalServerError) 1003 return 1004 } 1005 + h.jc.AddDid(data.Did) 1006 1007 if err := h.e.AddOwner(ThisServer, data.Did); err != nil { 1008 l.Error("adding owner", "error", err.Error()) 1009 writeError(w, err.Error(), http.StatusInternalServerError)
+24 -5
lexicons/pulls/pull.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["targetRepo", "targetBranch", "pullId", "title", "patch"], 13 "properties": { 14 "targetRepo": { 15 "type": "string", ··· 18 "targetBranch": { 19 "type": "string" 20 }, 21 - "sourceRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 "pullId": { 26 "type": "integer" 27 }, ··· 37 }, 38 "patch": { 39 "type": "string" 40 } 41 } 42 } 43 }
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "targetRepo", 14 + "targetBranch", 15 + "pullId", 16 + "title", 17 + "patch" 18 + ], 19 "properties": { 20 "targetRepo": { 21 "type": "string", ··· 24 "targetBranch": { 25 "type": "string" 26 }, 27 "pullId": { 28 "type": "integer" 29 }, ··· 39 }, 40 "patch": { 41 "type": "string" 42 + }, 43 + "source": { 44 + "type": "ref", 45 + "ref": "#source" 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" 60 } 61 } 62 }
+5
lexicons/repo.json
··· 32 "format": "datetime", 33 "minLength": 1, 34 "maxLength": 140 35 } 36 } 37 }
··· 32 "format": "datetime", 33 "minLength": 1, 34 "maxLength": 140 35 + }, 36 + "source": { 37 + "type": "string", 38 + "format": "uri", 39 + "description": "source of the repo" 40 } 41 } 42 }
+168
patchutil/combinediff.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + // original1 -> patch1 -> rev1 11 + // original2 -> patch2 -> rev2 12 + // 13 + // original2 must be equal to rev1, so we can merge them to get maximal context 14 + // 15 + // finally, 16 + // rev2' <- apply(patch2, merged) 17 + // combineddiff <- diff(rev2', original1) 18 + func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) { 19 + fileName := bestName(file1) 20 + 21 + o1 := CreatePreImage(file1) 22 + r1 := CreatePostImage(file1) 23 + o2 := CreatePreImage(file2) 24 + 25 + merged, err := r1.Merge(&o2) 26 + if err != nil { 27 + return nil, err 28 + } 29 + 30 + r2Prime, err := merged.Apply(file2) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + // produce combined diff 36 + diff, err := Unified(o1.String(), fileName, r2Prime, fileName) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 42 + 43 + if len(parsed) != 1 { 44 + // no diff? the second commit reverted the changes from the first 45 + return nil, nil 46 + } 47 + 48 + return parsed[0], nil 49 + } 50 + 51 + // use empty lines for lines we are unaware of 52 + // 53 + // this raises an error only if the two patches were invalid or non-contiguous 54 + func mergeLines(old, new string) (string, error) { 55 + var i, j int 56 + 57 + // TODO: use strings.Lines 58 + linesOld := strings.Split(old, "\n") 59 + linesNew := strings.Split(new, "\n") 60 + 61 + result := []string{} 62 + 63 + for i < len(linesOld) || j < len(linesNew) { 64 + if i >= len(linesOld) { 65 + // rest of the file is populated from `new` 66 + result = append(result, linesNew[j]) 67 + j++ 68 + continue 69 + } 70 + 71 + if j >= len(linesNew) { 72 + // rest of the file is populated from `old` 73 + result = append(result, linesOld[i]) 74 + i++ 75 + continue 76 + } 77 + 78 + oldLine := linesOld[i] 79 + newLine := linesNew[j] 80 + 81 + if oldLine != newLine && (oldLine != "" && newLine != "") { 82 + // context mismatch 83 + return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine) 84 + } 85 + 86 + if oldLine == newLine { 87 + result = append(result, oldLine) 88 + } else if oldLine == "" { 89 + result = append(result, newLine) 90 + } else if newLine == "" { 91 + result = append(result, oldLine) 92 + } 93 + i++ 94 + j++ 95 + } 96 + 97 + return strings.Join(result, "\n"), nil 98 + } 99 + 100 + func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File { 101 + fileToIdx1 := make(map[string]int) 102 + fileToIdx2 := make(map[string]int) 103 + visited := make(map[string]struct{}) 104 + var result []*gitdiff.File 105 + 106 + for idx, f := range patch1 { 107 + fileToIdx1[bestName(f)] = idx 108 + } 109 + 110 + for idx, f := range patch2 { 111 + fileToIdx2[bestName(f)] = idx 112 + } 113 + 114 + for _, f1 := range patch1 { 115 + fileName := bestName(f1) 116 + if idx, ok := fileToIdx2[fileName]; ok { 117 + f2 := patch2[idx] 118 + 119 + // we have f1 and f2, combine them 120 + combined, err := combineFiles(f1, f2) 121 + if err != nil { 122 + fmt.Println(err) 123 + } 124 + 125 + result = append(result, combined) 126 + } else { 127 + // only in patch1; add as-is 128 + result = append(result, f1) 129 + } 130 + 131 + visited[fileName] = struct{}{} 132 + } 133 + 134 + // for all files in patch2 that remain unvisited; we can just add them into the output 135 + for _, f2 := range patch2 { 136 + fileName := bestName(f2) 137 + if _, ok := visited[fileName]; ok { 138 + continue 139 + } 140 + 141 + result = append(result, f2) 142 + } 143 + 144 + return result 145 + } 146 + 147 + // pairwise combination from first to last patch 148 + func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File { 149 + if len(patches) == 0 { 150 + return nil 151 + } 152 + 153 + if len(patches) == 1 { 154 + return patches[0] 155 + } 156 + 157 + combined := combineTwo(patches[0], patches[1]) 158 + 159 + newPatches := [][]*gitdiff.File{} 160 + newPatches = append(newPatches, combined) 161 + for i, p := range patches { 162 + if i >= 2 { 163 + newPatches = append(newPatches, p) 164 + } 165 + } 166 + 167 + return CombineDiff(newPatches...) 168 + }
+178
patchutil/image.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluekeyes/go-gitdiff/gitdiff" 9 + ) 10 + 11 + type Line struct { 12 + LineNumber int64 13 + Content string 14 + IsUnknown bool 15 + } 16 + 17 + func NewLineAt(lineNumber int64, content string) Line { 18 + return Line{ 19 + LineNumber: lineNumber, 20 + Content: content, 21 + IsUnknown: false, 22 + } 23 + } 24 + 25 + type Image struct { 26 + File string 27 + Data []*Line 28 + } 29 + 30 + func (r *Image) String() string { 31 + var i, j int64 32 + var b strings.Builder 33 + for { 34 + i += 1 35 + 36 + if int(j) >= (len(r.Data)) { 37 + break 38 + } 39 + 40 + if r.Data[j].LineNumber == i { 41 + // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) 42 + b.WriteString(r.Data[j].Content) 43 + j += 1 44 + } else { 45 + //b.WriteString(fmt.Sprintf("%d:\n", i)) 46 + b.WriteString("\n") 47 + } 48 + } 49 + 50 + return b.String() 51 + } 52 + 53 + func (r *Image) AddLine(line *Line) { 54 + r.Data = append(r.Data, line) 55 + } 56 + 57 + // rebuild the original file from a patch 58 + func CreatePreImage(file *gitdiff.File) Image { 59 + rf := Image{ 60 + File: bestName(file), 61 + } 62 + 63 + for _, fragment := range file.TextFragments { 64 + position := fragment.OldPosition 65 + for _, line := range fragment.Lines { 66 + switch line.Op { 67 + case gitdiff.OpContext: 68 + rl := NewLineAt(position, line.Line) 69 + rf.Data = append(rf.Data, &rl) 70 + position += 1 71 + case gitdiff.OpDelete: 72 + rl := NewLineAt(position, line.Line) 73 + rf.Data = append(rf.Data, &rl) 74 + position += 1 75 + case gitdiff.OpAdd: 76 + // do nothing here 77 + } 78 + } 79 + } 80 + 81 + return rf 82 + } 83 + 84 + // rebuild the revised file from a patch 85 + func CreatePostImage(file *gitdiff.File) Image { 86 + rf := Image{ 87 + File: bestName(file), 88 + } 89 + 90 + for _, fragment := range file.TextFragments { 91 + position := fragment.NewPosition 92 + for _, line := range fragment.Lines { 93 + switch line.Op { 94 + case gitdiff.OpContext: 95 + rl := NewLineAt(position, line.Line) 96 + rf.Data = append(rf.Data, &rl) 97 + position += 1 98 + case gitdiff.OpAdd: 99 + rl := NewLineAt(position, line.Line) 100 + rf.Data = append(rf.Data, &rl) 101 + position += 1 102 + case gitdiff.OpDelete: 103 + // do nothing here 104 + } 105 + } 106 + } 107 + 108 + return rf 109 + } 110 + 111 + type MergeError struct { 112 + msg string 113 + mismatchingLine int64 114 + } 115 + 116 + func (m MergeError) Error() string { 117 + return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) 118 + } 119 + 120 + // best effort merging of two reconstructed files 121 + func (this *Image) Merge(other *Image) (*Image, error) { 122 + mergedFile := Image{} 123 + 124 + var i, j int64 125 + 126 + for int(i) < len(this.Data) || int(j) < len(other.Data) { 127 + if int(i) >= len(this.Data) { 128 + // first file is done; the rest of the lines from file 2 can go in 129 + mergedFile.AddLine(other.Data[j]) 130 + j++ 131 + continue 132 + } 133 + 134 + if int(j) >= len(other.Data) { 135 + // first file is done; the rest of the lines from file 2 can go in 136 + mergedFile.AddLine(this.Data[i]) 137 + i++ 138 + continue 139 + } 140 + 141 + line1 := this.Data[i] 142 + line2 := other.Data[j] 143 + 144 + if line1.LineNumber == line2.LineNumber { 145 + if line1.Content != line2.Content { 146 + return nil, MergeError{ 147 + msg: "mismatching lines, this patch might have undergone rebase", 148 + mismatchingLine: line1.LineNumber, 149 + } 150 + } else { 151 + mergedFile.AddLine(line1) 152 + } 153 + i++ 154 + j++ 155 + } else if line1.LineNumber < line2.LineNumber { 156 + mergedFile.AddLine(line1) 157 + i++ 158 + } else { 159 + mergedFile.AddLine(line2) 160 + j++ 161 + } 162 + } 163 + 164 + return &mergedFile, nil 165 + } 166 + 167 + func (r *Image) Apply(patch *gitdiff.File) (string, error) { 168 + original := r.String() 169 + var buffer bytes.Buffer 170 + reader := strings.NewReader(original) 171 + 172 + err := gitdiff.Apply(&buffer, reader, patch) 173 + if err != nil { 174 + return "", err 175 + } 176 + 177 + return buffer.String(), nil 178 + }
+244
patchutil/interdiff.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + type InterdiffResult struct { 11 + Files []*InterdiffFile 12 + } 13 + 14 + func (i *InterdiffResult) AffectedFiles() []string { 15 + files := make([]string, len(i.Files)) 16 + for _, f := range i.Files { 17 + files = append(files, f.Name) 18 + } 19 + return files 20 + } 21 + 22 + func (i *InterdiffResult) String() string { 23 + var b strings.Builder 24 + for _, f := range i.Files { 25 + b.WriteString(f.String()) 26 + b.WriteString("\n") 27 + } 28 + 29 + return b.String() 30 + } 31 + 32 + type InterdiffFile struct { 33 + *gitdiff.File 34 + Name string 35 + Status InterdiffFileStatus 36 + } 37 + 38 + func (s *InterdiffFile) String() string { 39 + var b strings.Builder 40 + b.WriteString(s.Status.String()) 41 + b.WriteString(" ") 42 + 43 + if s.File != nil { 44 + b.WriteString(bestName(s.File)) 45 + b.WriteString("\n") 46 + b.WriteString(s.File.String()) 47 + } 48 + 49 + return b.String() 50 + } 51 + 52 + type InterdiffFileStatus struct { 53 + StatusKind StatusKind 54 + Error error 55 + } 56 + 57 + func (s *InterdiffFileStatus) String() string { 58 + kind := s.StatusKind.String() 59 + if s.Error != nil { 60 + return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 61 + } else { 62 + return kind 63 + } 64 + } 65 + 66 + func (s *InterdiffFileStatus) IsOk() bool { 67 + return s.StatusKind == StatusOk 68 + } 69 + 70 + func (s *InterdiffFileStatus) IsUnchanged() bool { 71 + return s.StatusKind == StatusUnchanged 72 + } 73 + 74 + func (s *InterdiffFileStatus) IsOnlyInOne() bool { 75 + return s.StatusKind == StatusOnlyInOne 76 + } 77 + 78 + func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 79 + return s.StatusKind == StatusOnlyInTwo 80 + } 81 + 82 + func (s *InterdiffFileStatus) IsRebased() bool { 83 + return s.StatusKind == StatusRebased 84 + } 85 + 86 + func (s *InterdiffFileStatus) IsError() bool { 87 + return s.StatusKind == StatusError 88 + } 89 + 90 + type StatusKind int 91 + 92 + func (k StatusKind) String() string { 93 + switch k { 94 + case StatusOnlyInOne: 95 + return "only in one" 96 + case StatusOnlyInTwo: 97 + return "only in two" 98 + case StatusUnchanged: 99 + return "unchanged" 100 + case StatusRebased: 101 + return "rebased" 102 + case StatusError: 103 + return "error" 104 + default: 105 + return "changed" 106 + } 107 + } 108 + 109 + const ( 110 + StatusOk StatusKind = iota 111 + StatusOnlyInOne 112 + StatusOnlyInTwo 113 + StatusUnchanged 114 + StatusRebased 115 + StatusError 116 + ) 117 + 118 + func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 119 + re1 := CreatePreImage(f1) 120 + re2 := CreatePreImage(f2) 121 + 122 + interdiffFile := InterdiffFile{ 123 + Name: bestName(f1), 124 + } 125 + 126 + merged, err := re1.Merge(&re2) 127 + if err != nil { 128 + interdiffFile.Status = InterdiffFileStatus{ 129 + StatusKind: StatusRebased, 130 + Error: err, 131 + } 132 + return &interdiffFile 133 + } 134 + 135 + rev1, err := merged.Apply(f1) 136 + if err != nil { 137 + interdiffFile.Status = InterdiffFileStatus{ 138 + StatusKind: StatusError, 139 + Error: err, 140 + } 141 + return &interdiffFile 142 + } 143 + 144 + rev2, err := merged.Apply(f2) 145 + if err != nil { 146 + interdiffFile.Status = InterdiffFileStatus{ 147 + StatusKind: StatusError, 148 + Error: err, 149 + } 150 + return &interdiffFile 151 + } 152 + 153 + diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 154 + if err != nil { 155 + interdiffFile.Status = InterdiffFileStatus{ 156 + StatusKind: StatusError, 157 + Error: err, 158 + } 159 + return &interdiffFile 160 + } 161 + 162 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 163 + if err != nil { 164 + interdiffFile.Status = InterdiffFileStatus{ 165 + StatusKind: StatusError, 166 + Error: err, 167 + } 168 + return &interdiffFile 169 + } 170 + 171 + if len(parsed) != 1 { 172 + // files are identical? 173 + interdiffFile.Status = InterdiffFileStatus{ 174 + StatusKind: StatusUnchanged, 175 + } 176 + return &interdiffFile 177 + } 178 + 179 + if interdiffFile.Status.StatusKind == StatusOk { 180 + interdiffFile.File = parsed[0] 181 + } 182 + 183 + return &interdiffFile 184 + } 185 + 186 + func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 187 + fileToIdx1 := make(map[string]int) 188 + fileToIdx2 := make(map[string]int) 189 + visited := make(map[string]struct{}) 190 + var result InterdiffResult 191 + 192 + for idx, f := range patch1 { 193 + fileToIdx1[bestName(f)] = idx 194 + } 195 + 196 + for idx, f := range patch2 { 197 + fileToIdx2[bestName(f)] = idx 198 + } 199 + 200 + for _, f1 := range patch1 { 201 + var interdiffFile *InterdiffFile 202 + 203 + fileName := bestName(f1) 204 + if idx, ok := fileToIdx2[fileName]; ok { 205 + f2 := patch2[idx] 206 + 207 + // we have f1 and f2, calculate interdiff 208 + interdiffFile = interdiffFiles(f1, f2) 209 + } else { 210 + // only in patch 1, this change would have to be "inverted" to dissapear 211 + // from patch 2, so we reverseDiff(f1) 212 + reverseDiff(f1) 213 + 214 + interdiffFile = &InterdiffFile{ 215 + File: f1, 216 + Name: fileName, 217 + Status: InterdiffFileStatus{ 218 + StatusKind: StatusOnlyInOne, 219 + }, 220 + } 221 + } 222 + 223 + result.Files = append(result.Files, interdiffFile) 224 + visited[fileName] = struct{}{} 225 + } 226 + 227 + // for all files in patch2 that remain unvisited; we can just add them into the output 228 + for _, f2 := range patch2 { 229 + fileName := bestName(f2) 230 + if _, ok := visited[fileName]; ok { 231 + continue 232 + } 233 + 234 + result.Files = append(result.Files, &InterdiffFile{ 235 + File: f2, 236 + Name: fileName, 237 + Status: InterdiffFileStatus{ 238 + StatusKind: StatusOnlyInTwo, 239 + }, 240 + }) 241 + } 242 + 243 + return &result 244 + }
+196
patchutil/patchutil.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "regexp" 8 + "strings" 9 + 10 + "github.com/bluekeyes/go-gitdiff/gitdiff" 11 + ) 12 + 13 + type FormatPatch struct { 14 + Files []*gitdiff.File 15 + *gitdiff.PatchHeader 16 + } 17 + 18 + func ExtractPatches(formatPatch string) ([]FormatPatch, error) { 19 + patches := splitFormatPatch(formatPatch) 20 + 21 + result := []FormatPatch{} 22 + 23 + for _, patch := range patches { 24 + files, headerStr, err := gitdiff.Parse(strings.NewReader(patch)) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse patch: %w", err) 27 + } 28 + 29 + header, err := gitdiff.ParsePatchHeader(headerStr) 30 + if err != nil { 31 + return nil, fmt.Errorf("failed to parse patch header: %w", err) 32 + } 33 + 34 + result = append(result, FormatPatch{ 35 + Files: files, 36 + PatchHeader: header, 37 + }) 38 + } 39 + 40 + return result, nil 41 + } 42 + 43 + // IsPatchValid checks if the given patch string is valid. 44 + // It performs very basic sniffing for either git-diff or git-format-patch 45 + // header lines. For format patches, it attempts to extract and validate each one. 46 + func IsPatchValid(patch string) bool { 47 + if len(patch) == 0 { 48 + return false 49 + } 50 + 51 + lines := strings.Split(patch, "\n") 52 + if len(lines) < 2 { 53 + return false 54 + } 55 + 56 + firstLine := strings.TrimSpace(lines[0]) 57 + 58 + // check if it's a git diff 59 + if strings.HasPrefix(firstLine, "diff ") || 60 + strings.HasPrefix(firstLine, "--- ") || 61 + strings.HasPrefix(firstLine, "Index: ") || 62 + strings.HasPrefix(firstLine, "+++ ") || 63 + strings.HasPrefix(firstLine, "@@ ") { 64 + return true 65 + } 66 + 67 + // check if it's format-patch 68 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") || 69 + strings.HasPrefix(firstLine, "From: ") { 70 + // ExtractPatches already runs it through gitdiff.Parse so if that errors, 71 + // it's safe to say it's broken. 72 + patches, err := ExtractPatches(patch) 73 + if err != nil { 74 + return false 75 + } 76 + return len(patches) > 0 77 + } 78 + 79 + return false 80 + } 81 + 82 + func IsFormatPatch(patch string) bool { 83 + lines := strings.Split(patch, "\n") 84 + if len(lines) < 2 { 85 + return false 86 + } 87 + 88 + firstLine := strings.TrimSpace(lines[0]) 89 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") { 90 + return true 91 + } 92 + 93 + headerCount := 0 94 + for i := range min(10, len(lines)) { 95 + line := strings.TrimSpace(lines[i]) 96 + if strings.HasPrefix(line, "From: ") || 97 + strings.HasPrefix(line, "Date: ") || 98 + strings.HasPrefix(line, "Subject: ") || 99 + strings.HasPrefix(line, "commit ") { 100 + headerCount++ 101 + } 102 + } 103 + 104 + return headerCount >= 2 105 + } 106 + 107 + func splitFormatPatch(patchText string) []string { 108 + re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`) 109 + 110 + indexes := re.FindAllStringIndex(patchText, -1) 111 + 112 + if len(indexes) == 0 { 113 + return []string{} 114 + } 115 + 116 + patches := make([]string, len(indexes)) 117 + 118 + for i := range indexes { 119 + startPos := indexes[i][0] 120 + endPos := len(patchText) 121 + 122 + if i < len(indexes)-1 { 123 + endPos = indexes[i+1][0] 124 + } 125 + 126 + patches[i] = strings.TrimSpace(patchText[startPos:endPos]) 127 + } 128 + return patches 129 + } 130 + 131 + func bestName(file *gitdiff.File) string { 132 + if file.IsDelete { 133 + return file.OldName 134 + } else { 135 + return file.NewName 136 + } 137 + } 138 + 139 + // in-place reverse of a diff 140 + func reverseDiff(file *gitdiff.File) { 141 + file.OldName, file.NewName = file.NewName, file.OldName 142 + file.OldMode, file.NewMode = file.NewMode, file.OldMode 143 + file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 144 + 145 + for _, fragment := range file.TextFragments { 146 + // swap postions 147 + fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 148 + fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 149 + fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 150 + 151 + for i := range fragment.Lines { 152 + switch fragment.Lines[i].Op { 153 + case gitdiff.OpAdd: 154 + fragment.Lines[i].Op = gitdiff.OpDelete 155 + case gitdiff.OpDelete: 156 + fragment.Lines[i].Op = gitdiff.OpAdd 157 + default: 158 + // do nothing 159 + } 160 + } 161 + } 162 + } 163 + 164 + func Unified(oldText, oldFile, newText, newFile string) (string, error) { 165 + oldTemp, err := os.CreateTemp("", "old_*") 166 + if err != nil { 167 + return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 168 + } 169 + defer os.Remove(oldTemp.Name()) 170 + if _, err := oldTemp.WriteString(oldText); err != nil { 171 + return "", fmt.Errorf("failed to write to old temp file: %w", err) 172 + } 173 + oldTemp.Close() 174 + 175 + newTemp, err := os.CreateTemp("", "new_*") 176 + if err != nil { 177 + return "", fmt.Errorf("failed to create temp file for newText: %w", err) 178 + } 179 + defer os.Remove(newTemp.Name()) 180 + if _, err := newTemp.WriteString(newText); err != nil { 181 + return "", fmt.Errorf("failed to write to new temp file: %w", err) 182 + } 183 + newTemp.Close() 184 + 185 + cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 186 + output, err := cmd.CombinedOutput() 187 + 188 + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 189 + return string(output), nil 190 + } 191 + if err != nil { 192 + return "", fmt.Errorf("diff command failed: %w", err) 193 + } 194 + 195 + return string(output), nil 196 + }
+324
patchutil/patchutil_test.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + ) 7 + 8 + func TestIsPatchValid(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + patch string 12 + expected bool 13 + }{ 14 + { 15 + name: `empty patch`, 16 + patch: ``, 17 + expected: false, 18 + }, 19 + { 20 + name: `single line patch`, 21 + patch: `single line`, 22 + expected: false, 23 + }, 24 + { 25 + name: `valid diff patch`, 26 + patch: `diff --git a/file.txt b/file.txt 27 + index abc..def 100644 28 + --- a/file.txt 29 + +++ b/file.txt 30 + @@ -1,3 +1,3 @@ 31 + -old line 32 + +new line 33 + context`, 34 + expected: true, 35 + }, 36 + { 37 + name: `valid patch starting with ---`, 38 + patch: `--- a/file.txt 39 + +++ b/file.txt 40 + @@ -1,3 +1,3 @@ 41 + -old line 42 + +new line 43 + context`, 44 + expected: true, 45 + }, 46 + { 47 + name: `valid patch starting with Index`, 48 + patch: `Index: file.txt 49 + ========== 50 + --- a/file.txt 51 + +++ b/file.txt 52 + @@ -1,3 +1,3 @@ 53 + -old line 54 + +new line 55 + context`, 56 + expected: true, 57 + }, 58 + { 59 + name: `valid patch starting with +++`, 60 + patch: `+++ b/file.txt 61 + --- a/file.txt 62 + @@ -1,3 +1,3 @@ 63 + -old line 64 + +new line 65 + context`, 66 + expected: true, 67 + }, 68 + { 69 + name: `valid patch starting with @@`, 70 + patch: `@@ -1,3 +1,3 @@ 71 + -old line 72 + +new line 73 + context 74 + `, 75 + expected: true, 76 + }, 77 + { 78 + name: `valid format patch`, 79 + patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 80 + From: Author <author@example.com> 81 + Date: Wed, 16 Apr 2025 11:01:00 +0300 82 + Subject: [PATCH] Example patch 83 + 84 + diff --git a/file.txt b/file.txt 85 + index 123456..789012 100644 86 + --- a/file.txt 87 + +++ b/file.txt 88 + @@ -1 +1 @@ 89 + -old content 90 + +new content 91 + -- 92 + 2.48.1`, 93 + expected: true, 94 + }, 95 + { 96 + name: `invalid format patch`, 97 + patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 + From: Author <author@example.com> 99 + This is not a valid patch format`, 100 + expected: false, 101 + }, 102 + { 103 + name: `not a patch at all`, 104 + patch: `This is 105 + just some 106 + random text 107 + that isn't a patch`, 108 + expected: false, 109 + }, 110 + } 111 + 112 + for _, tt := range tests { 113 + t.Run(tt.name, func(t *testing.T) { 114 + result := IsPatchValid(tt.patch) 115 + if result != tt.expected { 116 + t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 + } 118 + }) 119 + } 120 + } 121 + 122 + func TestSplitPatches(t *testing.T) { 123 + tests := []struct { 124 + name string 125 + input string 126 + expected []string 127 + }{ 128 + { 129 + name: "Empty input", 130 + input: "", 131 + expected: []string{}, 132 + }, 133 + { 134 + name: "No valid patches", 135 + input: "This is not a \nJust some random text", 136 + expected: []string{}, 137 + }, 138 + { 139 + name: "Single patch", 140 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 141 + From: Author <author@example.com> 142 + Date: Wed, 16 Apr 2025 11:01:00 +0300 143 + Subject: [PATCH] Example patch 144 + 145 + diff --git a/file.txt b/file.txt 146 + index 123456..789012 100644 147 + --- a/file.txt 148 + +++ b/file.txt 149 + @@ -1 +1 @@ 150 + -old content 151 + +new content 152 + -- 153 + 2.48.1`, 154 + expected: []string{ 155 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 156 + From: Author <author@example.com> 157 + Date: Wed, 16 Apr 2025 11:01:00 +0300 158 + Subject: [PATCH] Example patch 159 + 160 + diff --git a/file.txt b/file.txt 161 + index 123456..789012 100644 162 + --- a/file.txt 163 + +++ b/file.txt 164 + @@ -1 +1 @@ 165 + -old content 166 + +new content 167 + -- 168 + 2.48.1`, 169 + }, 170 + }, 171 + { 172 + name: "Two patches", 173 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 174 + From: Author <author@example.com> 175 + Date: Wed, 16 Apr 2025 11:01:00 +0300 176 + Subject: [PATCH 1/2] First patch 177 + 178 + diff --git a/file1.txt b/file1.txt 179 + index 123456..789012 100644 180 + --- a/file1.txt 181 + +++ b/file1.txt 182 + @@ -1 +1 @@ 183 + -old content 184 + +new content 185 + -- 186 + 2.48.1 187 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 188 + From: Author <author@example.com> 189 + Date: Wed, 16 Apr 2025 11:03:11 +0300 190 + Subject: [PATCH 2/2] Second patch 191 + 192 + diff --git a/file2.txt b/file2.txt 193 + index abcdef..ghijkl 100644 194 + --- a/file2.txt 195 + +++ b/file2.txt 196 + @@ -1 +1 @@ 197 + -foo bar 198 + +baz qux 199 + -- 200 + 2.48.1`, 201 + expected: []string{ 202 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 203 + From: Author <author@example.com> 204 + Date: Wed, 16 Apr 2025 11:01:00 +0300 205 + Subject: [PATCH 1/2] First patch 206 + 207 + diff --git a/file1.txt b/file1.txt 208 + index 123456..789012 100644 209 + --- a/file1.txt 210 + +++ b/file1.txt 211 + @@ -1 +1 @@ 212 + -old content 213 + +new content 214 + -- 215 + 2.48.1`, 216 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 217 + From: Author <author@example.com> 218 + Date: Wed, 16 Apr 2025 11:03:11 +0300 219 + Subject: [PATCH 2/2] Second patch 220 + 221 + diff --git a/file2.txt b/file2.txt 222 + index abcdef..ghijkl 100644 223 + --- a/file2.txt 224 + +++ b/file2.txt 225 + @@ -1 +1 @@ 226 + -foo bar 227 + +baz qux 228 + -- 229 + 2.48.1`, 230 + }, 231 + }, 232 + { 233 + name: "Patches with additional text between them", 234 + input: `Some text before the patches 235 + 236 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 237 + From: Author <author@example.com> 238 + Subject: [PATCH] First patch 239 + 240 + diff content here 241 + -- 242 + 2.48.1 243 + 244 + Some text between patches 245 + 246 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 247 + From: Author <author@example.com> 248 + Subject: [PATCH] Second patch 249 + 250 + more diff content 251 + -- 252 + 2.48.1 253 + 254 + Text after patches`, 255 + expected: []string{ 256 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 257 + From: Author <author@example.com> 258 + Subject: [PATCH] First patch 259 + 260 + diff content here 261 + -- 262 + 2.48.1 263 + 264 + Some text between patches`, 265 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 266 + From: Author <author@example.com> 267 + Subject: [PATCH] Second patch 268 + 269 + more diff content 270 + -- 271 + 2.48.1 272 + 273 + Text after patches`, 274 + }, 275 + }, 276 + { 277 + name: "Patches with whitespace padding", 278 + input: ` 279 + 280 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 281 + From: Author <author@example.com> 282 + Subject: Patch 283 + 284 + content 285 + -- 286 + 2.48.1 287 + 288 + 289 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 290 + From: Author <author@example.com> 291 + Subject: Another patch 292 + 293 + content 294 + -- 295 + 2.48.1 296 + `, 297 + expected: []string{ 298 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 299 + From: Author <author@example.com> 300 + Subject: Patch 301 + 302 + content 303 + -- 304 + 2.48.1`, 305 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 306 + From: Author <author@example.com> 307 + Subject: Another patch 308 + 309 + content 310 + -- 311 + 2.48.1`, 312 + }, 313 + }, 314 + } 315 + 316 + for _, tt := range tests { 317 + t.Run(tt.name, func(t *testing.T) { 318 + result := splitFormatPatch(tt.input) 319 + if !reflect.DeepEqual(result, tt.expected) { 320 + t.Errorf("splitPatches() = %v, want %v", result, tt.expected) 321 + } 322 + }) 323 + } 324 + }
+55 -34
rbac/rbac.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 - "path" 7 "strings" 8 9 adapter "github.com/Blank-Xu/sql-adapter" ··· 26 e = some(where (p.eft == allow)) 27 28 [matchers] 29 - m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom) 30 ` 31 ) 32 ··· 34 E *casbin.Enforcer 35 } 36 37 - func keyMatch2(key1 string, key2 string) bool { 38 - matched, _ := path.Match(key2, key1) 39 - return matched 40 - } 41 - 42 func NewEnforcer(path string) (*Enforcer, error) { 43 m, err := model.NewModelFromString(Model) 44 if err != nil { ··· 61 } 62 63 e.EnableAutoSave(false) 64 - 65 - e.AddFunction("keyMatch2", keyMatch2Func) 66 67 return &Enforcer{e}, nil 68 } ··· 96 return err 97 } 98 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{ 106 {member, domain, repo, "repo:settings"}, 107 {member, domain, repo, "repo:push"}, 108 {member, domain, repo, "repo:owner"}, 109 {member, domain, repo, "repo:invite"}, 110 {member, domain, repo, "repo:delete"}, 111 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 112 - }) 113 return err 114 } 115 116 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) 120 } 121 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 - }) 127 return err 128 } 129 ··· 165 return e.E.Enforce(user, domain, repo, "repo:settings") 166 } 167 168 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 169 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 170 var permissions []string ··· 179 return permissions 180 } 181 182 - func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 183 - return e.E.Enforce(user, domain, repo, "repo:invite") 184 - } 185 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 192 }
··· 3 import ( 4 "database/sql" 5 "fmt" 6 "strings" 7 8 adapter "github.com/Blank-Xu/sql-adapter" ··· 25 e = some(where (p.eft == allow)) 26 27 [matchers] 28 + m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom) 29 ` 30 ) 31 ··· 33 E *casbin.Enforcer 34 } 35 36 func NewEnforcer(path string) (*Enforcer, error) { 37 m, err := model.NewModelFromString(Model) 38 if err != nil { ··· 55 } 56 57 e.EnableAutoSave(false) 58 59 return &Enforcer{e}, nil 60 } ··· 88 return err 89 } 90 91 + func repoPolicies(member, domain, repo string) [][]string { 92 + return [][]string{ 93 {member, domain, repo, "repo:settings"}, 94 {member, domain, repo, "repo:push"}, 95 {member, domain, repo, "repo:owner"}, 96 {member, domain, repo, "repo:invite"}, 97 {member, domain, repo, "repo:delete"}, 98 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 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)) 108 return err 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 + ) 129 130 func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error { 131 + err := checkRepoFormat(repo) 132 + if err != nil { 133 + return err 134 } 135 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)) 147 return err 148 } 149 ··· 185 return e.E.Enforce(user, domain, repo, "repo:settings") 186 } 187 188 + func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 189 + return e.E.Enforce(user, domain, repo, "repo:invite") 190 + } 191 + 192 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 193 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 194 var permissions []string ··· 203 return permissions 204 } 205 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 + } 211 212 + return nil 213 }
+8 -89
readme.md
··· 6 7 Read the introduction to Tangled [here](https://blog.tangled.sh/intro). 8 9 - ## knot self-hosting guide 10 11 - So you want to run your own knot server? Great! Here are a few prerequisites: 12 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 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.
··· 6 7 Read the introduction to Tangled [here](https://blog.tangled.sh/intro). 8 9 + ## docs 10 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)&mdash;**read this before opening a PR!** 15 16 + ## security 17 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
··· 1 /** @type {import('tailwindcss').Config} */ 2 - const colors = require('tailwindcss/colors') 3 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html"], 6 theme: { 7 container: { 8 padding: "2rem", ··· 12 md: "600px", 13 lg: "800px", 14 xl: "1000px", 15 - "2xl": "1200px" 16 }, 17 }, 18 extend: { 19 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"], 22 }, 23 typography: { 24 DEFAULT: { 25 css: { 26 - maxWidth: 'none', 27 pre: { 28 backgroundColor: colors.gray[100], 29 color: colors.black, 30 }, 31 }, 32 }, 33 }, 34 }, 35 }, 36 - plugins: [ 37 - require('@tailwindcss/typography'), 38 - ] 39 };
··· 1 /** @type {import('tailwindcss').Config} */ 2 + const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 + darkMode: "media", 7 theme: { 8 container: { 9 padding: "2rem", ··· 13 md: "600px", 14 lg: "800px", 15 xl: "1000px", 16 + "2xl": "1200px", 17 }, 18 }, 19 extend: { 20 fontFamily: { 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 + ], 33 }, 34 typography: { 35 DEFAULT: { 36 css: { 37 + maxWidth: "none", 38 pre: { 39 backgroundColor: colors.gray[100], 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": {} 66 }, 67 }, 68 }, 69 }, 70 }, 71 }, 72 + plugins: [require("@tailwindcss/typography")], 73 };
+10
types/capabilities.go
···
··· 1 + package types 2 + 3 + type Capabilities struct { 4 + PullRequests struct { 5 + FormatPatch bool `json:"format_patch"` 6 + PatchSubmissions bool `json:"patch_submissions"` 7 + BranchSubmissions bool `json:"branch_submissions"` 8 + ForkSubmissions bool `json:"fork_submissions"` 9 + } `json:"pull_requests"` 10 + }
+35
types/diff.go
··· 23 IsRename bool `json:"is_rename"` 24 } 25 26 // A nicer git diff representation. 27 type NiceDiff struct { 28 Commit struct { ··· 38 } `json:"stat"` 39 Diff []Diff `json:"diff"` 40 }
··· 23 IsRename bool `json:"is_rename"` 24 } 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 + 40 // A nicer git diff representation. 41 type NiceDiff struct { 42 Commit struct { ··· 52 } `json:"stat"` 53 Diff []Diff `json:"diff"` 54 } 55 + 56 + type DiffTree struct { 57 + Rev1 string `json:"rev1"` 58 + Rev2 string `json:"rev2"` 59 + Patch string `json:"patch"` 60 + Diff []*gitdiff.File `json:"diff"` 61 + } 62 + 63 + func (d *NiceDiff) ChangedFiles() []string { 64 + files := make([]string, len(d.Diff)) 65 + 66 + for i, f := range d.Diff { 67 + if f.IsDelete { 68 + files[i] = f.Name.Old 69 + } else { 70 + files[i] = f.Name.New 71 + } 72 + } 73 + 74 + return files 75 + }
+16
types/repo.go
··· 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 ) 6 7 type RepoIndexResponse struct { ··· 30 type RepoCommitResponse struct { 31 Ref string `json:"ref,omitempty"` 32 Diff *NiceDiff `json:"diff,omitempty"` 33 } 34 35 type RepoTreeResponse struct { ··· 61 62 type RepoBranchesResponse struct { 63 Branches []Branch `json:"branches,omitempty"` 64 } 65 66 type RepoBlobResponse struct {
··· 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 + "tangled.sh/tangled.sh/core/patchutil" 6 ) 7 8 type RepoIndexResponse struct { ··· 31 type RepoCommitResponse struct { 32 Ref string `json:"ref,omitempty"` 33 Diff *NiceDiff `json:"diff,omitempty"` 34 + } 35 + 36 + type RepoFormatPatchResponse struct { 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"` 40 + Patch string `json:"patch,omitempty"` 41 } 42 43 type RepoTreeResponse struct { ··· 69 70 type RepoBranchesResponse struct { 71 Branches []Branch `json:"branches,omitempty"` 72 + } 73 + 74 + type RepoBranchResponse struct { 75 + Branch Branch `json:"branch,omitempty"` 76 + } 77 + 78 + type RepoDefaultBranchResponse struct { 79 + Branch string `json:"branch,omitempty"` 80 } 81 82 type RepoBlobResponse struct {