From 4fac824966bef8dccd8d45008781237bca0d5a6f Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 1 Apr 2025 12:44:33 +0300 Subject: [PATCH] appview: implement repo fork --- api/tangled/cbor_gen.go | 59 +++++- api/tangled/tangledrepo.go | 2 + appview/db/db.go | 7 + appview/db/repos.go | 40 +++- appview/db/timeline.go | 17 ++ appview/pages/pages.go | 30 ++- appview/pages/templates/layouts/repobase.html | 10 + appview/pages/templates/repo/fork.html | 38 ++++ appview/pages/templates/repo/settings.html | 25 ++- appview/pages/templates/timeline.html | 7 + appview/state/repo.go | 193 +++++++++++++++++- appview/state/repo_util.go | 14 ++ appview/state/router.go | 6 + appview/state/signer.go | 20 ++ appview/state/state.go | 3 + knotserver/git/fork.go | 20 ++ knotserver/handler.go | 1 + knotserver/routes.go | 43 ++++ lexicons/repo.json | 5 + 19 files changed, 514 insertions(+), 26 deletions(-) create mode 100644 appview/pages/templates/repo/fork.html create mode 100644 knotserver/git/fork.go diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 5c7fa1d7..61cab855 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -1753,7 +1753,7 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { } cw := cbg.NewCborWriter(w) - fieldCount := 6 + fieldCount := 7 if t.AddedAt == nil { fieldCount-- @@ -1763,6 +1763,10 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { fieldCount-- } + if t.Source == nil { + fieldCount-- + } + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { return err } @@ -1855,6 +1859,38 @@ func (t *Repo) MarshalCBOR(w io.Writer) error { return err } + // t.Source (string) (string) + if t.Source != nil { + + if len("source") > 1000000 { + return xerrors.Errorf("Value in field \"source\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil { + return err + } + if _, err := cw.WriteString(string("source")); err != nil { + return err + } + + if t.Source == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Source) > 1000000 { + return xerrors.Errorf("Value in field t.Source was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Source)); err != nil { + return err + } + } + } + // t.AddedAt (string) (string) if t.AddedAt != nil { @@ -2006,6 +2042,27 @@ func (t *Repo) UnmarshalCBOR(r io.Reader) (err error) { t.Owner = string(sval) } + // t.Source (string) (string) + case "source": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Source = (*string)(&sval) + } + } // t.AddedAt (string) (string) case "addedAt": diff --git a/api/tangled/tangledrepo.go b/api/tangled/tangledrepo.go index 2970a7c0..36eaccac 100644 --- a/api/tangled/tangledrepo.go +++ b/api/tangled/tangledrepo.go @@ -25,4 +25,6 @@ type Repo struct { // name: name of the repo Name string `json:"name" cborgen:"name"` Owner string `json:"owner" cborgen:"owner"` + // source: source of the repo + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` } diff --git a/appview/db/db.go b/appview/db/db.go index 644ee10a..2c3ed401 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -273,6 +273,13 @@ func Make(dbPath string) (*DB, error) { return err }) + runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { + _, err := tx.Exec(` + alter table repos add column source text; + `) + return err + }) + return &DB{db}, nil } diff --git a/appview/db/repos.go b/appview/db/repos.go index c2492771..cacdce05 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -3,6 +3,8 @@ package db import ( "database/sql" "time" + + "github.com/bluesky-social/indigo/atproto/syntax" ) type Repo struct { @@ -16,13 +18,16 @@ type Repo struct { // optionally, populate this when querying for reverse mappings RepoStats *RepoStats + + // optional + Source string } func GetAllRepos(e Execer, limit int) ([]Repo, error) { var repos []Repo rows, err := e.Query( - `select did, name, knot, rkey, description, created + `select did, name, knot, rkey, description, created, source from repos order by created desc limit ? @@ -37,7 +42,7 @@ func GetAllRepos(e Execer, limit int) ([]Repo, error) { for rows.Next() { var repo Repo err := scanRepo( - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, + rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, ) if err != nil { return nil, err @@ -63,7 +68,8 @@ func GetAllReposByDid(e Execer, did string) ([]Repo, error) { r.rkey, r.description, r.created, - count(s.id) as star_count + count(s.id) as star_count, + r.source from repos r left join @@ -159,10 +165,10 @@ func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { func AddRepo(e Execer, repo *Repo) error { _, err := e.Exec( - `insert into repos - (did, name, knot, rkey, at_uri, description) - values (?, ?, ?, ?, ?, ?)`, - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, + `insert into repos + (did, name, knot, rkey, at_uri, description, source) + values (?, ?, ?, ?, ?, ?, ?)`, + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, ) return err } @@ -172,6 +178,15 @@ func RemoveRepo(e Execer, did, name, rkey string) error { return err } +func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) { + var source string + err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&source) + if err != nil { + return "", err + } + return source, nil +} + func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { _, err := e.Exec( `insert into collaborators (did, repo) @@ -249,10 +264,11 @@ type RepoStats struct { PullCount PullCount } -func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error { +func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { var createdAt string var nullableDescription sql.NullString - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil { + var nullableSource sql.NullString + if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { return err } @@ -269,5 +285,11 @@ func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, create *created = createdAtTime } + if nullableSource.Valid { + *source = nullableSource.String + } else { + *source = "" + } + return nil } diff --git a/appview/db/timeline.go b/appview/db/timeline.go index 961db89b..b5223bc4 100644 --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -9,7 +9,11 @@ type TimelineEvent struct { *Repo *Follow *Star + EventAt time.Time + + // optional: populate only if Repo is a fork + Source *Repo } // TODO: this gathers heterogenous events from different sources and aggregates @@ -34,6 +38,19 @@ func MakeTimeline(e Execer) ([]TimelineEvent, error) { } for _, repo := range repos { + if repo.Source != "" { + sourceRepo, err := GetRepoByAtUri(e, repo.Source) + if err != nil { + return nil, err + } + + events = append(events, TimelineEvent{ + Repo: &repo, + EventAt: repo.Created, + Source: sourceRepo, + }) + } + events = append(events, TimelineEvent{ Repo: &repo, EventAt: repo.Created, diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 93bbccaa..925517e9 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -158,6 +158,16 @@ func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { return p.execute("repo/new", w, params) } +type ForkRepoParams struct { + LoggedInUser *auth.User + Knots []string + RepoInfo RepoInfo +} + +func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { + return p.execute("repo/fork", w, params) +} + type ProfilePageParams struct { LoggedInUser *auth.User UserDid string @@ -212,15 +222,17 @@ func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParam } type RepoInfo struct { - Name string - OwnerDid string - OwnerHandle string - Description string - Knot string - RepoAt syntax.ATURI - IsStarred bool - Stats db.RepoStats - Roles RolesInRepo + Name string + OwnerDid string + OwnerHandle string + Description string + Knot string + RepoAt syntax.ATURI + IsStarred bool + Stats db.RepoStats + Roles RolesInRepo + Source *db.Repo + SourceHandle string } type RolesInRepo struct { diff --git a/appview/pages/templates/layouts/repobase.html b/appview/pages/templates/layouts/repobase.html index 28efb0b6..e367c07d 100644 --- a/appview/pages/templates/layouts/repobase.html +++ b/appview/pages/templates/layouts/repobase.html @@ -2,6 +2,16 @@ {{ define "content" }}
+ {{ if .RepoInfo.Source }} +

+

+ {{ i "git-fork" "w-3 h-3 mr-1"}} + forked from + {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} + {{ $sourceOwner }}/{{ .RepoInfo.Source.Name }} +
+

+ {{ end }}

{{ .RepoInfo.OwnerWithAt }} / diff --git a/appview/pages/templates/repo/fork.html b/appview/pages/templates/repo/fork.html new file mode 100644 index 00000000..cb1b81bb --- /dev/null +++ b/appview/pages/templates/repo/fork.html @@ -0,0 +1,38 @@ +{{ define "title" }}fork · {{ .RepoInfo.FullName }}{{ end }} + +{{ define "content" }} +

+

Fork {{ .RepoInfo.FullName }}

+
+
+
+
+ Select a knot to fork into +
+
+ {{ range .Knots }} +
+ + {{ . }} +
+ {{ else }} +

No knots available.

+ {{ end }} +
+
+

A knot hosts repository data. Learn how to register your own knot.

+
+ +
+ +
+
+
+
+{{ end }} diff --git a/appview/pages/templates/repo/settings.html b/appview/pages/templates/repo/settings.html index f034e9aa..ceef3b21 100644 --- a/appview/pages/templates/repo/settings.html +++ b/appview/pages/templates/repo/settings.html @@ -1,6 +1,8 @@ {{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }} {{ define "repoContent" }} -
Collaborators
+
+ Collaborators +
{{ range .Collaborators }} @@ -21,11 +23,24 @@
{{ if .IsCollaboratorInviteAllowed }} -

add collaborator

- - - + + +
{{ end }} diff --git a/appview/pages/templates/timeline.html b/appview/pages/templates/timeline.html index 88b6bfd2..d3d0941e 100644 --- a/appview/pages/templates/timeline.html +++ b/appview/pages/templates/timeline.html @@ -44,8 +44,15 @@

{{ $userHandle | truncateAt30 }} + {{ if .Source }} + forked + {{ .Repo.Name }} + from + {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }} + {{ else }} created {{ .Repo.Name }} + {{ end }}

diff --git a/appview/state/repo.go b/appview/state/repo.go index 61458395..ebd0c872 100644 --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -2,11 +2,13 @@ package state import ( "context" + "database/sql" "encoding/json" + "errors" "fmt" "io" "log" - "math/rand/v2" + mathrand "math/rand/v2" "net/http" "path" "slices" @@ -801,12 +803,34 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { if err != nil { log.Println("failed to get issue count for ", f.RepoAt) } + source, err := db.GetRepoSource(s.db, f.RepoAt) + if errors.Is(err, sql.ErrNoRows) { + source = "" + } else if err != nil { + log.Println("failed to get repo source for ", f.RepoAt) + } + + var sourceRepo *db.Repo + if source != "" { + sourceRepo, err = db.GetRepoByAtUri(s.db, source) + if err != nil { + log.Println("failed to get repo by at uri", err) + } + } knot := f.Knot if knot == "knot1.tangled.sh" { knot = "tangled.sh" } + var sourceHandle *identity.Identity + if sourceRepo != nil { + sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) + if err != nil { + log.Println("failed to resolve source repo", err) + } + } + return pages.RepoInfo{ OwnerDid: f.OwnerDid(), OwnerHandle: f.OwnerHandle(), @@ -821,6 +845,8 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { IssueCount: issueCount, PullCount: pullCount, }, + Source: sourceRepo, + SourceHandle: sourceHandle.Handle.String(), } } @@ -1022,7 +1048,7 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { return } - commentId := rand.IntN(1000000) + commentId := mathrand.IntN(1000000) rkey := s.TID() err := db.NewIssueComment(s.db, &db.Comment{ @@ -1481,3 +1507,166 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { return } } + +func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + f, err := fullyResolvedRepo(r) + if err != nil { + log.Printf("failed to resolve source repo: %v", err) + return + } + + switch r.Method { + case http.MethodGet: + user := s.auth.GetUser(r) + knots, err := s.enforcer.GetDomainsForUser(user.Did) + if err != nil { + s.pages.Notice(w, "repo", "Invalid user account.") + return + } + + s.pages.ForkRepo(w, pages.ForkRepoParams{ + LoggedInUser: user, + Knots: knots, + RepoInfo: f.RepoInfo(s, user), + }) + + case http.MethodPost: + + knot := r.FormValue("knot") + if knot == "" { + s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") + return + } + + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") + if err != nil || !ok { + s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") + return + } + + forkName := fmt.Sprintf("%s", f.RepoName) + + existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) + if err == nil && existingRepo != nil { + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) + } + + secret, err := db.GetRegistrationKey(s.db, knot) + if err != nil { + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) + return + } + + client, err := NewSignedClient(knot, secret, s.config.Dev) + if err != nil { + s.pages.Notice(w, "repo", "Failed to connect to knot server.") + return + } + + var uri string + if s.config.Dev { + uri = "http" + } else { + uri = "https" + } + sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, knot, f.OwnerDid(), f.RepoName) + sourceAt := f.RepoAt.String() + + rkey := s.TID() + repo := &db.Repo{ + Did: user.Did, + Name: forkName, + Knot: knot, + Rkey: rkey, + Source: sourceAt, + } + + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println(err) + s.pages.Notice(w, "repo", "Failed to save repository information.") + return + } + defer func() { + tx.Rollback() + err = s.enforcer.E.LoadPolicy() + if err != nil { + log.Println("failed to rollback policies") + } + }() + + resp, err := client.ForkRepo(user.Did, sourceUrl, forkName) + if err != nil { + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") + return + } + + switch resp.StatusCode { + case http.StatusConflict: + s.pages.Notice(w, "repo", "A repository with that name already exists.") + return + case http.StatusInternalServerError: + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") + case http.StatusNoContent: + // continue + } + + xrpcClient, _ := s.auth.AuthorizedClient(r) + + addedAt := time.Now().Format(time.RFC3339) + atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ + Collection: tangled.RepoNSID, + Repo: user.Did, + Rkey: rkey, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.Repo{ + Knot: repo.Knot, + Name: repo.Name, + AddedAt: &addedAt, + Owner: user.Did, + Source: &sourceAt, + }}, + }) + if err != nil { + log.Printf("failed to create record: %s", err) + s.pages.Notice(w, "repo", "Failed to announce repository creation.") + return + } + log.Println("created repo record: ", atresp.Uri) + + repo.AtUri = atresp.Uri + err = db.AddRepo(tx, repo) + if err != nil { + log.Println(err) + s.pages.Notice(w, "repo", "Failed to save repository information.") + return + } + + // acls + p, _ := securejoin.SecureJoin(user.Did, forkName) + err = s.enforcer.AddRepo(user.Did, knot, p) + if err != nil { + log.Println(err) + s.pages.Notice(w, "repo", "Failed to set up repository permissions.") + return + } + + err = tx.Commit() + if err != nil { + log.Println("failed to commit changes", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = s.enforcer.E.SavePolicy() + if err != nil { + log.Println("failed to update ACLs", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) + return + } +} diff --git a/appview/state/repo_util.go b/appview/state/repo_util.go index d7066234..b5be04c3 100644 --- a/appview/state/repo_util.go +++ b/appview/state/repo_util.go @@ -2,8 +2,10 @@ package state import ( "context" + "crypto/rand" "fmt" "log" + "math/big" "net/http" "github.com/bluesky-social/indigo/atproto/identity" @@ -112,3 +114,15 @@ func EmailToDidOrHandle(s *State, emails []string) map[string]string { return emailToDidOrHandle } + +func randomString(n int) string { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + result := make([]byte, n) + + for i := 0; i < n; i++ { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + result[i] = letters[n.Int64()] + } + + return string(result) +} diff --git a/appview/state/router.go b/appview/state/router.go index 45d1e910..a7822318 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -85,6 +85,12 @@ func (s *State) UserRouter() http.Handler { }) }) + r.Route("/fork", func(r chi.Router) { + r.Use(AuthMiddleware(s)) + r.Get("/", s.ForkRepo) + r.Post("/", s.ForkRepo) + }) + r.Route("/pulls", func(r chi.Router) { r.Get("/", s.RepoPulls) r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) { diff --git a/appview/state/signer.go b/appview/state/signer.go index 4aa7493b..7127cb20 100644 --- a/appview/state/signer.go +++ b/appview/state/signer.go @@ -103,6 +103,26 @@ func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Respo return s.client.Do(req) } +func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { + const ( + Method = "POST" + Endpoint = "/repo/fork" + ) + + body, _ := json.Marshal(map[string]any{ + "did": ownerDid, + "source": source, + "name": name, + }) + + req, err := s.newRequest(Method, Endpoint, body) + if err != nil { + return nil, err + } + + return s.client.Do(req) +} + func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { const ( Method = "DELETE" diff --git a/appview/state/state.go b/appview/state/state.go index 6f468880..1893edc8 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -190,6 +190,9 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { for _, ev := range timeline { if ev.Repo != nil { didsToResolve = append(didsToResolve, ev.Repo.Did) + if ev.Source != nil { + didsToResolve = append(didsToResolve, ev.Source.Did) + } } if ev.Follow != nil { didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) diff --git a/knotserver/git/fork.go b/knotserver/git/fork.go new file mode 100644 index 00000000..b75c28c2 --- /dev/null +++ b/knotserver/git/fork.go @@ -0,0 +1,20 @@ +package git + +import ( + "fmt" + + "github.com/go-git/go-git/v5" +) + +func Fork(repoPath, source string) error { + _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ + URL: source, + Depth: 1, + SingleBranch: false, + }) + + if err != nil { + return fmt.Errorf("failed to bare clone repository: %w", err) + } + return nil +} diff --git a/knotserver/handler.go b/knotserver/handler.go index 84a8062d..499c169d 100644 --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -120,6 +120,7 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j r.Use(h.VerifySignature) r.Put("/new", h.NewRepo) r.Delete("/", h.RemoveRepo) + r.Post("/fork", h.RepoFork) }) r.Route("/member", func(r chi.Router) { diff --git a/knotserver/routes.go b/knotserver/routes.go index 5fa7a52d..fe97d1fd 100644 --- a/knotserver/routes.go +++ b/knotserver/routes.go @@ -577,6 +577,49 @@ func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { + l := h.l.With("handler", "RepoFork") + + data := struct { + Did string `json:"did"` + Source string `json:"source"` + Name string `json:"name,omitempty"` + }{} + + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + did := data.Did + source := data.Source + + if did == "" || source == "" { + l.Error("invalid request body, empty did or name") + w.WriteHeader(http.StatusBadRequest) + return + } + + var name string + if data.Name != "" { + name = data.Name + } else { + name = filepath.Base(source) + } + + relativeRepoPath := filepath.Join(did, name) + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) + + err := git.Fork(repoPath, source) + if err != nil { + l.Error("forking repo", "error", err.Error()) + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { l := h.l.With("handler", "RemoveRepo") diff --git a/lexicons/repo.json b/lexicons/repo.json index 578facd4..42612b91 100644 --- a/lexicons/repo.json +++ b/lexicons/repo.json @@ -32,6 +32,11 @@ "format": "datetime", "minLength": 1, "maxLength": 140 + }, + "source": { + "type": "string", + "format": "uri", + "description": "source of the repo" } } } -- 2.51.2