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

appview: implement repo fork

+58 -1
api/tangled/cbor_gen.go
···
cw := cbg.NewCborWriter(w)
-
fieldCount := 6
+
fieldCount := 7
if t.AddedAt == nil {
fieldCount--
if t.Description == nil {
+
fieldCount--
+
}
+
+
if t.Source == nil {
fieldCount--
···
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 {
···
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":
+2
api/tangled/tangledrepo.go
···
// 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"`
}
+7
appview/db/db.go
···
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
}
+31 -9
appview/db/repos.go
···
import (
"database/sql"
"time"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
)
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 ?
···
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
···
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
···
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
}
···
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)
···
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
}
···
*created = time.Now()
} else {
*created = createdAtTime
+
}
+
+
if nullableSource.Valid {
+
*source = nullableSource.String
+
} else {
+
*source = ""
}
return nil
+17
appview/db/timeline.go
···
*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
···
}
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,
+21 -9
appview/pages/pages.go
···
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
···
}
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 {
+10
appview/pages/templates/layouts/repobase.html
···
{{ define "content" }}
<section id="repo-header" class="mb-4 py-2 px-6 dark:text-white">
+
{{ if .RepoInfo.Source }}
+
<p class="text-sm">
+
<div class="flex items-center">
+
{{ i "git-fork" "w-3 h-3 mr-1"}}
+
forked from
+
{{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }}
+
<a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a>
+
</div>
+
</p>
+
{{ end }}
<p class="text-lg">
<a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a>
<span class="select-none">/</span>
+38
appview/pages/templates/repo/fork.html
···
+
{{ define "title" }}fork &middot; {{ .RepoInfo.FullName }}{{ end }}
+
+
{{ define "content" }}
+
<div class="p-6">
+
<p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p>
+
</div>
+
<div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded">
+
<form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none">
+
<fieldset class="space-y-3">
+
<legend class="dark:text-white">Select a knot to fork into</legend>
+
<div class="space-y-2">
+
<div class="flex flex-col">
+
{{ range .Knots }}
+
<div class="flex items-center">
+
<input
+
type="radio"
+
name="knot"
+
value="{{ . }}"
+
class="mr-2"
+
id="domain-{{ . }}"
+
/>
+
<span class="dark:text-white">{{ . }}</span>
+
</div>
+
{{ else }}
+
<p class="dark:text-white">No knots available.</p>
+
{{ end }}
+
</div>
+
</div>
+
<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>
+
</fieldset>
+
+
<div class="space-y-2">
+
<button type="submit" class="btn">fork repo</button>
+
<div id="repo" class="error"></div>
+
</div>
+
</form>
+
</div>
+
{{ end }}
+20 -5
appview/pages/templates/repo/settings.html
···
{{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">Collaborators</header>
+
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
+
Collaborators
+
</header>
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
{{ range .Collaborators }}
···
</div>
{{ if .IsCollaboratorInviteAllowed }}
-
<h3 class="dark:text-white">add collaborator</h3>
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator">
-
<label for="collaborator" class="dark:text-white">did or handle:</label>
-
<input type="text" id="collaborator" name="collaborator" required class="dark:bg-gray-700 dark:text-white" />
-
<button class="btn my-2 dark:text-white dark:hover:bg-gray-700" type="text">add collaborator</button>
+
<label for="collaborator" class="dark:text-white"
+
>add collaborator</label
+
>
+
<input
+
type="text"
+
id="collaborator"
+
name="collaborator"
+
required
+
class="dark:bg-gray-700 dark:text-white"
+
placeholder="enter did or handle"
+
/>
+
<button
+
class="btn my-2 dark:text-white dark:hover:bg-gray-700"
+
type="text"
+
>
+
add
+
</button>
</form>
{{ end }}
+7
appview/pages/templates/timeline.html
···
<div class="flex items-center">
<p class="text-gray-600 dark:text-gray-300">
<a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a>
+
{{ if .Source }}
+
forked
+
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
+
from
+
<a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a>
+
{{ else }}
created
<a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a>
+
{{ end }}
<time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time>
</p>
</div>
+191 -2
appview/state/repo.go
···
import (
"context"
+
"database/sql"
"encoding/json"
+
"errors"
"fmt"
"io"
"log"
-
"math/rand/v2"
+
mathrand "math/rand/v2"
"net/http"
"path"
"slices"
···
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{
···
IssueCount: issueCount,
PullCount: pullCount,
},
+
Source: sourceRepo,
+
SourceHandle: sourceHandle.Handle.String(),
}
}
···
return
-
commentId := rand.IntN(1000000)
+
commentId := mathrand.IntN(1000000)
rkey := s.TID()
err := db.NewIssueComment(s.db, &db.Comment{
···
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&mdash;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
+
}
+
}
+14
appview/state/repo_util.go
···
import (
"context"
+
"crypto/rand"
"fmt"
"log"
+
"math/big"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
···
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)
+
}
+6
appview/state/router.go
···
})
})
+
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) {
+20
appview/state/signer.go
···
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"
+3
appview/state/state.go
···
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)
+20
knotserver/git/fork.go
···
+
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
+
}
+1
knotserver/handler.go
···
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) {
+43
knotserver/routes.go
···
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")
+5
lexicons/repo.json
···
"format": "datetime",
"minLength": 1,
"maxLength": 140
+
},
+
"source": {
+
"type": "string",
+
"format": "uri",
+
"description": "source of the repo"
}
}
}