Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview: implement repo fork

authored by anirudh.fi and committed by oppi.li 4fac8249 dc6c6d17

+454 -25
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/tangledrepo.go

This is a binary file and will not be displayed.

+7
appview/db/db.go
··· 273 273 return err 274 274 }) 275 275 276 + runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 277 + _, err := tx.Exec(` 278 + alter table repos add column source text; 279 + `) 280 + return err 281 + }) 282 + 276 283 return &DB{db}, nil 277 284 } 278 285
+31 -9
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 { ··· 18 16 19 17 // optionally, populate this when querying for reverse mappings 20 18 RepoStats *RepoStats 19 + 20 + // optional 21 + Source string 21 22 } 22 23 23 24 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 24 25 var repos []Repo 25 26 26 27 rows, err := e.Query( 27 - `select did, name, knot, rkey, description, created 28 + `select did, name, knot, rkey, description, created, source 28 29 from repos 29 30 order by created desc 30 31 limit ? ··· 42 37 for rows.Next() { 43 38 var repo Repo 44 39 err := scanRepo( 45 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, 40 + rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 46 41 ) 47 42 if err != nil { 48 43 return nil, err ··· 68 63 r.rkey, 69 64 r.description, 70 65 r.created, 71 - count(s.id) as star_count 66 + count(s.id) as star_count, 67 + r.source 72 68 from 73 69 repos r 74 70 left join ··· 165 159 166 160 func AddRepo(e Execer, repo *Repo) error { 167 161 _, err := e.Exec( 168 - `insert into repos 169 - (did, name, knot, rkey, at_uri, description) 170 - values (?, ?, ?, ?, ?, ?)`, 171 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, 162 + `insert into repos 163 + (did, name, knot, rkey, at_uri, description, source) 164 + values (?, ?, ?, ?, ?, ?, ?)`, 165 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 172 166 ) 173 167 return err 174 168 } ··· 176 170 func RemoveRepo(e Execer, did, name, rkey string) error { 177 171 _, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey) 178 172 return err 173 + } 174 + 175 + func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) { 176 + var source string 177 + err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&source) 178 + if err != nil { 179 + return "", err 180 + } 181 + return source, nil 179 182 } 180 183 181 184 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { ··· 264 249 PullCount PullCount 265 250 } 266 251 267 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error { 252 + func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 268 253 var createdAt string 269 254 var nullableDescription sql.NullString 270 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil { 255 + var nullableSource sql.NullString 256 + if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 271 257 return err 272 258 } 273 259 ··· 283 267 *created = time.Now() 284 268 } else { 285 269 *created = createdAtTime 270 + } 271 + 272 + if nullableSource.Valid { 273 + *source = nullableSource.String 274 + } else { 275 + *source = "" 286 276 } 287 277 288 278 return nil
+17
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 ··· 38 34 } 39 35 40 36 for _, repo := range repos { 37 + if repo.Source != "" { 38 + sourceRepo, err := GetRepoByAtUri(e, repo.Source) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + events = append(events, TimelineEvent{ 44 + Repo: &repo, 45 + EventAt: repo.Created, 46 + Source: sourceRepo, 47 + }) 48 + } 49 + 41 50 events = append(events, TimelineEvent{ 42 51 Repo: &repo, 43 52 EventAt: repo.Created,
+21 -9
appview/pages/pages.go
··· 158 158 return p.execute("repo/new", w, params) 159 159 } 160 160 161 + type ForkRepoParams struct { 162 + LoggedInUser *auth.User 163 + Knots []string 164 + RepoInfo RepoInfo 165 + } 166 + 167 + func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 168 + return p.execute("repo/fork", w, params) 169 + } 170 + 161 171 type ProfilePageParams struct { 162 172 LoggedInUser *auth.User 163 173 UserDid string ··· 222 212 } 223 213 224 214 type RepoInfo struct { 225 - Name string 226 - OwnerDid string 227 - OwnerHandle string 228 - Description string 229 - Knot string 230 - RepoAt syntax.ATURI 231 - IsStarred bool 232 - Stats db.RepoStats 233 - Roles RolesInRepo 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 224 + Source *db.Repo 225 + SourceHandle string 234 226 } 235 227 236 228 type RolesInRepo struct {
+10
appview/pages/templates/layouts/repobase.html
··· 2 2 3 3 {{ define "content" }} 4 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 }} 5 15 <p class="text-lg"> 6 16 <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 7 17 <span class="select-none">/</span>
+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 }}
+20 -5
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 }} ··· 23 21 </div> 24 22 25 23 {{ if .IsCollaboratorInviteAllowed }} 26 - <h3 class="dark:text-white">add collaborator</h3> 27 24 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 28 - <label for="collaborator" class="dark:text-white">did or handle:</label> 29 - <input type="text" id="collaborator" name="collaborator" required class="dark:bg-gray-700 dark:text-white" /> 30 - <button class="btn my-2 dark:text-white dark:hover:bg-gray-700" type="text">add collaborator</button> 25 + <label for="collaborator" class="dark:text-white" 26 + >add collaborator</label 27 + > 28 + <input 29 + type="text" 30 + id="collaborator" 31 + name="collaborator" 32 + required 33 + class="dark:bg-gray-700 dark:text-white" 34 + placeholder="enter did or handle" 35 + /> 36 + <button 37 + class="btn my-2 dark:text-white dark:hover:bg-gray-700" 38 + type="text" 39 + > 40 + add 41 + </button> 31 42 </form> 32 43 {{ end }} 33 44
+7
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 + {{ if .Source }} 48 + forked 49 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 50 + from 51 + <a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a> 52 + {{ else }} 47 53 created 48 54 <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 55 + {{ end }} 49 56 <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time> 50 57 </p> 51 58 </div>
+191 -2
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" ··· 803 801 if err != nil { 804 802 log.Println("failed to get issue count for ", f.RepoAt) 805 803 } 804 + source, err := db.GetRepoSource(s.db, f.RepoAt) 805 + if errors.Is(err, sql.ErrNoRows) { 806 + source = "" 807 + } else if err != nil { 808 + log.Println("failed to get repo source for ", f.RepoAt) 809 + } 810 + 811 + var sourceRepo *db.Repo 812 + if source != "" { 813 + sourceRepo, err = db.GetRepoByAtUri(s.db, source) 814 + if err != nil { 815 + log.Println("failed to get repo by at uri", err) 816 + } 817 + } 806 818 807 819 knot := f.Knot 808 820 if knot == "knot1.tangled.sh" { 809 821 knot = "tangled.sh" 822 + } 823 + 824 + var sourceHandle *identity.Identity 825 + if sourceRepo != nil { 826 + sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 827 + if err != nil { 828 + log.Println("failed to resolve source repo", err) 829 + } 810 830 } 811 831 812 832 return pages.RepoInfo{ ··· 845 821 IssueCount: issueCount, 846 822 PullCount: pullCount, 847 823 }, 824 + Source: sourceRepo, 825 + SourceHandle: sourceHandle.Handle.String(), 848 826 } 849 827 } 850 828 ··· 1048 1022 return 1049 1023 } 1050 1024 1051 - commentId := rand.IntN(1000000) 1025 + commentId := mathrand.IntN(1000000) 1052 1026 rkey := s.TID() 1053 1027 1054 1028 err := db.NewIssueComment(s.db, &db.Comment{ ··· 1504 1478 } 1505 1479 1506 1480 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1481 + return 1482 + } 1483 + } 1484 + 1485 + func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1486 + user := s.auth.GetUser(r) 1487 + f, err := fullyResolvedRepo(r) 1488 + if err != nil { 1489 + log.Printf("failed to resolve source repo: %v", err) 1490 + return 1491 + } 1492 + 1493 + switch r.Method { 1494 + case http.MethodGet: 1495 + user := s.auth.GetUser(r) 1496 + knots, err := s.enforcer.GetDomainsForUser(user.Did) 1497 + if err != nil { 1498 + s.pages.Notice(w, "repo", "Invalid user account.") 1499 + return 1500 + } 1501 + 1502 + s.pages.ForkRepo(w, pages.ForkRepoParams{ 1503 + LoggedInUser: user, 1504 + Knots: knots, 1505 + RepoInfo: f.RepoInfo(s, user), 1506 + }) 1507 + 1508 + case http.MethodPost: 1509 + 1510 + knot := r.FormValue("knot") 1511 + if knot == "" { 1512 + s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1513 + return 1514 + } 1515 + 1516 + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1517 + if err != nil || !ok { 1518 + s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1519 + return 1520 + } 1521 + 1522 + forkName := fmt.Sprintf("%s", f.RepoName) 1523 + 1524 + existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1525 + if err == nil && existingRepo != nil { 1526 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1527 + } 1528 + 1529 + secret, err := db.GetRegistrationKey(s.db, knot) 1530 + if err != nil { 1531 + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1532 + return 1533 + } 1534 + 1535 + client, err := NewSignedClient(knot, secret, s.config.Dev) 1536 + if err != nil { 1537 + s.pages.Notice(w, "repo", "Failed to connect to knot server.") 1538 + return 1539 + } 1540 + 1541 + var uri string 1542 + if s.config.Dev { 1543 + uri = "http" 1544 + } else { 1545 + uri = "https" 1546 + } 1547 + sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, knot, f.OwnerDid(), f.RepoName) 1548 + sourceAt := f.RepoAt.String() 1549 + 1550 + rkey := s.TID() 1551 + repo := &db.Repo{ 1552 + Did: user.Did, 1553 + Name: forkName, 1554 + Knot: knot, 1555 + Rkey: rkey, 1556 + Source: sourceAt, 1557 + } 1558 + 1559 + tx, err := s.db.BeginTx(r.Context(), nil) 1560 + if err != nil { 1561 + log.Println(err) 1562 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1563 + return 1564 + } 1565 + defer func() { 1566 + tx.Rollback() 1567 + err = s.enforcer.E.LoadPolicy() 1568 + if err != nil { 1569 + log.Println("failed to rollback policies") 1570 + } 1571 + }() 1572 + 1573 + resp, err := client.ForkRepo(user.Did, sourceUrl, forkName) 1574 + if err != nil { 1575 + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1576 + return 1577 + } 1578 + 1579 + switch resp.StatusCode { 1580 + case http.StatusConflict: 1581 + s.pages.Notice(w, "repo", "A repository with that name already exists.") 1582 + return 1583 + case http.StatusInternalServerError: 1584 + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1585 + case http.StatusNoContent: 1586 + // continue 1587 + } 1588 + 1589 + xrpcClient, _ := s.auth.AuthorizedClient(r) 1590 + 1591 + addedAt := time.Now().Format(time.RFC3339) 1592 + atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 1593 + Collection: tangled.RepoNSID, 1594 + Repo: user.Did, 1595 + Rkey: rkey, 1596 + Record: &lexutil.LexiconTypeDecoder{ 1597 + Val: &tangled.Repo{ 1598 + Knot: repo.Knot, 1599 + Name: repo.Name, 1600 + AddedAt: &addedAt, 1601 + Owner: user.Did, 1602 + Source: &sourceAt, 1603 + }}, 1604 + }) 1605 + if err != nil { 1606 + log.Printf("failed to create record: %s", err) 1607 + s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1608 + return 1609 + } 1610 + log.Println("created repo record: ", atresp.Uri) 1611 + 1612 + repo.AtUri = atresp.Uri 1613 + err = db.AddRepo(tx, repo) 1614 + if err != nil { 1615 + log.Println(err) 1616 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1617 + return 1618 + } 1619 + 1620 + // acls 1621 + p, _ := securejoin.SecureJoin(user.Did, forkName) 1622 + err = s.enforcer.AddRepo(user.Did, knot, p) 1623 + if err != nil { 1624 + log.Println(err) 1625 + s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1626 + return 1627 + } 1628 + 1629 + err = tx.Commit() 1630 + if err != nil { 1631 + log.Println("failed to commit changes", err) 1632 + http.Error(w, err.Error(), http.StatusInternalServerError) 1633 + return 1634 + } 1635 + 1636 + err = s.enforcer.E.SavePolicy() 1637 + if err != nil { 1638 + log.Println("failed to update ACLs", err) 1639 + http.Error(w, err.Error(), http.StatusInternalServerError) 1640 + return 1641 + } 1642 + 1643 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1507 1644 return 1508 1645 } 1509 1646 }
+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" ··· 113 111 } 114 112 115 113 return emailToDidOrHandle 114 + } 115 + 116 + func randomString(n int) string { 117 + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 118 + result := make([]byte, n) 119 + 120 + for i := 0; i < n; i++ { 121 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 122 + result[i] = letters[n.Int64()] 123 + } 124 + 125 + return string(result) 116 126 }
+6
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) {
+20
appview/state/signer.go
··· 103 103 return s.client.Do(req) 104 104 } 105 105 106 + func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 107 + const ( 108 + Method = "POST" 109 + Endpoint = "/repo/fork" 110 + ) 111 + 112 + body, _ := json.Marshal(map[string]any{ 113 + "did": ownerDid, 114 + "source": source, 115 + "name": name, 116 + }) 117 + 118 + req, err := s.newRequest(Method, Endpoint, body) 119 + if err != nil { 120 + return nil, err 121 + } 122 + 123 + return s.client.Do(req) 124 + } 125 + 106 126 func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 107 127 const ( 108 128 Method = "DELETE"
+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)
+20
knotserver/git/fork.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/go-git/go-git/v5" 7 + ) 8 + 9 + func Fork(repoPath, source string) error { 10 + _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 11 + URL: source, 12 + Depth: 1, 13 + SingleBranch: false, 14 + }) 15 + 16 + if err != nil { 17 + return fmt.Errorf("failed to bare clone repository: %w", err) 18 + } 19 + return nil 20 + }
+1
knotserver/handler.go
··· 120 120 r.Use(h.VerifySignature) 121 121 r.Put("/new", h.NewRepo) 122 122 r.Delete("/", h.RemoveRepo) 123 + r.Post("/fork", h.RepoFork) 123 124 }) 124 125 125 126 r.Route("/member", func(r chi.Router) {
+43
knotserver/routes.go
··· 577 577 w.WriteHeader(http.StatusNoContent) 578 578 } 579 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 + w.WriteHeader(http.StatusNoContent) 621 + } 622 + 580 623 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 581 624 l := h.l.With("handler", "RemoveRepo") 582 625
+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 }