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

Compare changes

Choose any two refs to compare.

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