···25 NewSha string `json:"newSha" cborgen:"newSha"`
26 // oldSha: old SHA of this ref
27 OldSha string `json:"oldSha" cborgen:"oldSha"`
0028 // ref: Ref being updated
29 Ref string `json:"ref" cborgen:"ref"`
30- // repoDid: did of the owner of the repo
31 RepoDid string `json:"repoDid" cborgen:"repoDid"`
32 // repoName: name of the repo
33 RepoName string `json:"repoName" cborgen:"repoName"`
···25 NewSha string `json:"newSha" cborgen:"newSha"`
26 // oldSha: old SHA of this ref
27 OldSha string `json:"oldSha" cborgen:"oldSha"`
28+ // ownerDid: did of the owner of the repo
29+ OwnerDid string `json:"ownerDid" cborgen:"ownerDid"`
30 // ref: Ref being updated
31 Ref string `json:"ref" cborgen:"ref"`
32+ // repoDid: DID of the repo itself
33 RepoDid string `json:"repoDid" cborgen:"repoDid"`
34 // repoName: name of the repo
35 RepoName string `json:"repoName" cborgen:"repoName"`
+2-1
api/tangled/labelop.go
···22 Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
23 PerformedAt string `json:"performedAt" cborgen:"performedAt"`
24 // subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
25- Subject string `json:"subject" cborgen:"subject"`
026}
2728// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
···22 Delete []*LabelOp_Operand `json:"delete" cborgen:"delete"`
23 PerformedAt string `json:"performedAt" cborgen:"performedAt"`
24 // subject: The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op.
25+ Subject string `json:"subject" cborgen:"subject"`
26+ SubjectDid *string `json:"subjectDid,omitempty" cborgen:"subjectDid,omitempty"`
27}
2829// LabelOp_Operand is a "operand" in the sh.tangled.label.op schema.
+2-1
api/tangled/repoartifact.go
···25 // name: name of the artifact
26 Name string `json:"name" cborgen:"name"`
27 // repo: repo that this artifact is being uploaded to
28- Repo string `json:"repo" cborgen:"repo"`
029 // tag: hash of the tag object that this artifact is attached to (only annotated tags are supported)
30 Tag util.LexBytes `json:"tag,omitempty" cborgen:"tag,omitempty"`
31}
···25 // name: name of the artifact
26 Name string `json:"name" cborgen:"name"`
27 // repo: repo that this artifact is being uploaded to
28+ Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
29+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
30 // tag: hash of the tag object that this artifact is attached to (only annotated tags are supported)
31 Tag util.LexBytes `json:"tag,omitempty" cborgen:"tag,omitempty"`
32}
+3-2
api/tangled/repocollaborator.go
···20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21 CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22 // repo: repo to add this user to
23- Repo string `json:"repo" cborgen:"repo"`
24- Subject string `json:"subject" cborgen:"subject"`
025}
···20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21 CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22 // repo: repo to add this user to
23+ Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
24+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
25+ Subject string `json:"subject" cborgen:"subject"`
26}
+14-4
api/tangled/repocreate.go
···18type RepoCreate_Input struct {
19 // defaultBranch: Default branch to push to
20 DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
000021 // rkey: Rkey of the repository record
22 Rkey string `json:"rkey" cborgen:"rkey"`
23 // source: A source URL to clone from, populate this when forking or importing a repository.
24 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
0000025}
2627// RepoCreate calls the XRPC method "sh.tangled.repo.create".
28-func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error {
29- if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil {
30- return err
031 }
3233- return nil
34}
···18type RepoCreate_Input struct {
19 // defaultBranch: Default branch to push to
20 DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
21+ // name: Name of the repository
22+ Name string `json:"name" cborgen:"name"`
23+ // repoDid: Optional user-provided did:web to use as the repo identity instead of minting a did:plc.
24+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
25 // rkey: Rkey of the repository record
26 Rkey string `json:"rkey" cborgen:"rkey"`
27 // source: A source URL to clone from, populate this when forking or importing a repository.
28 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
29+}
30+31+// RepoCreate_Output is the output of a sh.tangled.repo.create call.
32+type RepoCreate_Output struct {
33+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
34}
3536// RepoCreate calls the XRPC method "sh.tangled.repo.create".
37+func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) (*RepoCreate_Output, error) {
38+ var out RepoCreate_Output
39+ if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, &out); err != nil {
40+ return nil, err
41 }
4243+ return &out, nil
44}
···3334// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
35type RepoPull_Source struct {
36- Branch string `json:"branch" cborgen:"branch"`
37- Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
38- Sha string `json:"sha" cborgen:"sha"`
039}
4041// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
42type RepoPull_Target struct {
43- Branch string `json:"branch" cborgen:"branch"`
44- Repo string `json:"repo" cborgen:"repo"`
045}
···3334// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
35type RepoPull_Source struct {
36+ Branch string `json:"branch" cborgen:"branch"`
37+ Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
38+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
39+ Sha string `json:"sha" cborgen:"sha"`
40}
4142// RepoPull_Target is a "target" in the sh.tangled.repo.pull schema.
43type RepoPull_Target struct {
44+ Branch string `json:"branch" cborgen:"branch"`
45+ Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
46+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
47}
+2
api/tangled/tangledpipeline.go
···70 Did string `json:"did" cborgen:"did"`
71 Knot string `json:"knot" cborgen:"knot"`
72 Repo string `json:"repo" cborgen:"repo"`
0073}
7475// Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema.
···70 Did string `json:"did" cborgen:"did"`
71 Knot string `json:"knot" cborgen:"knot"`
72 Repo string `json:"repo" cborgen:"repo"`
73+ // repoDid: DID of the repo itself
74+ RepoDid string `json:"repoDid" cborgen:"repoDid"`
75}
7677// Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema.
+2
api/tangled/tangledrepo.go
···26 Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"`
27 // name: name of the repo
28 Name string `json:"name" cborgen:"name"`
0029 // source: source of the repo
30 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
31 // spindle: CI runner to send jobs to and receive results from
···26 Labels []string `json:"labels,omitempty" cborgen:"labels,omitempty"`
27 // name: name of the repo
28 Name string `json:"name" cborgen:"name"`
29+ // repoDid: DID of the repo itself, if assigned
30+ RepoDid *string `json:"repoDid,omitempty" cborgen:"repoDid,omitempty"`
31 // source: source of the repo
32 Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
33 // spindle: CI runner to send jobs to and receive results from
···75 // TODO: get this in the original query; requires COALESCE because nullable
76 var sourceRepo *models.Repo
77 if repo.Source != "" {
78- sourceRepo, err = GetRepoByAtUri(e, repo.Source)
000079 if err != nil {
80- // the source repo was not found, skip this bit
81 log.Println("profile", "err", err)
82 }
83 }
···449 query = `select count(id) from repos where did = ?`
450 args = append(args, did)
451 case models.VanityStatStarCount:
452- query = `select count(id) from stars where subject_at like 'at://' || ? || '%'`
453 args = append(args, did)
454 case models.VanityStatNone:
455 return 0, nil
···75 // TODO: get this in the original query; requires COALESCE because nullable
76 var sourceRepo *models.Repo
77 if repo.Source != "" {
78+ if strings.HasPrefix(repo.Source, "did:") {
79+ sourceRepo, err = GetRepoByDid(e, repo.Source)
80+ } else {
81+ sourceRepo, err = GetRepoByAtUri(e, repo.Source)
82+ }
83 if err != nil {
084 log.Println("profile", "err", err)
85 }
86 }
···452 query = `select count(id) from repos where did = ?`
453 args = append(args, did)
454 case models.VanityStatStarCount:
455+ query = `select count(s.id) from stars s join repos r on (s.subject_at = r.at_uri or (s.subject_did is not null and s.subject_did = r.repo_did)) where r.did = ?`
456 args = append(args, did)
457 case models.VanityStatNone:
458 return 0, nil
···7)
89type Star struct {
10- Did string
11- RepoAt syntax.ATURI
12- Created time.Time
13- Rkey string
014}
1516// RepoStar is used for reverse mapping to repos
···7)
89type Star struct {
10+ Did string
11+ RepoAt syntax.ATURI
12+ SubjectDid string
13+ Created time.Time
14+ Rkey string
15}
1617// RepoStar is used for reverse mapping to repos
+1
appview/models/webhook.go
···16type Webhook struct {
17 Id int64
18 RepoAt syntax.ATURI
019 Url string
20 Secret string
21 Active bool
···37 <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/settings/knots" class="underline">Learn how to register your own knot.</a></p>
38 </fieldset>
3900000000000000000000040 <div class="space-y-2">
41 <button type="submit" class="btn-create flex items-center gap-2">
42 {{ i "git-fork" "w-4 h-4" }}
···37 <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/settings/knots" class="underline">Learn how to register your own knot.</a></p>
38 </fieldset>
3940+ <fieldset class="space-y-3">
41+ <details>
42+ <summary class="dark:text-white cursor-pointer select-none">Bring your own DID</summary>
43+ <div class="mt-2">
44+ <input
45+ type="text"
46+ id="repo_did"
47+ name="repo_did"
48+ class="w-full p-2 border rounded bg-gray-100 dark:bg-gray-700 dark:text-white dark:border-gray-600"
49+ placeholder="did:web:example.com"
50+ />
51+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
52+ Provide a <code>did:web</code> you control to use as this fork's identity.
53+ You must serve a DID doc on your domain with an <code>atproto_pds</code> service
54+ endpoint pointing to the selected knot. If left empty, a <code>did:plc</code> will be
55+ automatically created for you!
56+ </p>
57+ </div>
58+ </details>
59+ </fieldset>
60+61 <div class="space-y-2">
62 <button type="submit" class="btn-create flex items-center gap-2">
63 {{ i "git-fork" "w-4 h-4" }}
+26
appview/pages/templates/repo/new.html
···70 <div class="space-y-2">
71 {{ template "defaultBranch" . }}
72 {{ template "knot" . }}
073 </div>
74 </div>
75 </div>
···168 A knot hosts repository data and handles Git operations.
169 You can also <a href="/settings/knots" class="underline">register your own knot</a>.
170 </p>
0000000000000000000000000171 </div>
172{{ end }}
173
···70 <div class="space-y-2">
71 {{ template "defaultBranch" . }}
72 {{ template "knot" . }}
73+ {{ template "repoDid" . }}
74 </div>
75 </div>
76 </div>
···169 A knot hosts repository data and handles Git operations.
170 You can also <a href="/settings/knots" class="underline">register your own knot</a>.
171 </p>
172+ </div>
173+{{ end }}
174+175+{{ define "repoDid" }}
176+ <div>
177+ <details>
178+ <summary class="text-sm font-bold uppercase dark:text-white mb-1 cursor-pointer select-none">
179+ Bring your own DID
180+ </summary>
181+ <div class="mt-2">
182+ <input
183+ type="text"
184+ id="repo_did"
185+ name="repo_did"
186+ class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 border border-gray-300 rounded px-3 py-2"
187+ placeholder="did:web:example.com"
188+ />
189+ <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
190+ Provide a <code>did:web</code> you control to use as this repo's identity.
191+ You must serve a DID doc on your domain with an <code>atproto_pds</code> service
192+ endpoint pointing to the selected knot. If left empty, a <code>did:plc</code> will be
193+ automatically created for you!
194+ </p>
195+ </div>
196+ </details>
197 </div>
198{{ end }}
199
···23import (
4 "context"
05 "encoding/json"
6 "errors"
7 "fmt"
···86 return err
87 }
880000089 knownKnots, err := enforcer.GetKnotsForUser(record.CommitterDid)
90 if err != nil {
91 return err
···9697 logger.Info("processing gitRefUpdate event",
98 "repo_did", record.RepoDid,
99- "repo_name", record.RepoName,
100 "ref", record.Ref,
101 "old_sha", record.OldSha,
102 "new_sha", record.NewSha)
103104- // trigger webhook notifications first (before other ops that might fail)
105 var errWebhook error
106- repos, err := db.GetRepos(
107- d,
108- 0,
109- orm.FilterEq("did", record.RepoDid),
110- orm.FilterEq("name", record.RepoName),
111- )
112- if err != nil {
113- errWebhook = fmt.Errorf("failed to lookup repo for webhooks: %w", err)
114- } else if len(repos) == 1 {
000115 notifier.Push(ctx, &repos[0], record.Ref, record.OldSha, record.NewSha, record.CommitterDid)
116 }
117···167168func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error {
169 if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil {
170- return fmt.Errorf("empty language data for repo: %s/%s", record.RepoDid, record.RepoName)
171 }
172173- repos, err := db.GetRepos(
174- d,
175- 0,
176- orm.FilterEq("did", record.RepoDid),
177- orm.FilterEq("name", record.RepoName),
178- )
179- if err != nil {
180- return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err)
181 }
182- if len(repos) != 1 {
183- return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
00184 }
185- repo := repos[0]
186187 ref := plumbing.ReferenceName(record.Ref)
188 if !ref.IsBranch() {
···197198 langs = append(langs, models.RepoLanguage{
199 RepoAt: repo.RepoAt(),
0200 Ref: ref.Short(),
201 IsDefaultRef: record.Meta.IsDefaultRef,
202 Language: l.Lang,
···235 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
236 }
237238- // does this repo have a spindle configured?
239- repos, err := db.GetRepos(
240- d,
241- 0,
242- orm.FilterEq("did", record.TriggerMetadata.Repo.Did),
243- orm.FilterEq("name", record.TriggerMetadata.Repo.Repo),
244- )
245- if err != nil {
246- return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err)
247 }
248- if len(repos) != 1 {
249- return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos))
00250 }
0251 if repos[0].Spindle == "" {
252 return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
253 }
···285 Knot: source.Key(),
286 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
287 RepoName: record.TriggerMetadata.Repo.Repo,
0288 TriggerId: int(triggerId),
289 Sha: sha,
290 }
···23import (
4 "context"
5+ "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
···87 return err
88 }
8990+ if record.RepoDid == "" {
91+ logger.Error("gitRefUpdate missing repoDid, skipping", "owner_did", record.OwnerDid, "repo_name", record.RepoName)
92+ return fmt.Errorf("gitRefUpdate missing repoDid")
93+ }
94+95 knownKnots, err := enforcer.GetKnotsForUser(record.CommitterDid)
96 if err != nil {
97 return err
···102103 logger.Info("processing gitRefUpdate event",
104 "repo_did", record.RepoDid,
0105 "ref", record.Ref,
106 "old_sha", record.OldSha,
107 "new_sha", record.NewSha)
1080109 var errWebhook error
110+111+ repo, lookupErr := db.GetRepoByDid(d, record.RepoDid)
112+ if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
113+ return fmt.Errorf("failed to look up repo by DID %s: %w", record.RepoDid, lookupErr)
114+ }
115+116+ var repos []models.Repo
117+ if lookupErr == nil {
118+ repos = []models.Repo{*repo}
119+ }
120+121+ if errWebhook == nil && len(repos) == 1 {
122 notifier.Push(ctx, &repos[0], record.Ref, record.OldSha, record.NewSha, record.CommitterDid)
123 }
124···174175func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error {
176 if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil {
177+ return fmt.Errorf("empty language data for repo: %s/%s", record.OwnerDid, record.RepoName)
178 }
179180+ if record.RepoDid == "" {
181+ return fmt.Errorf("gitRefUpdate missing repoDid for language update")
000000182 }
183+184+ r, lookupErr := db.GetRepoByDid(d, record.RepoDid)
185+ if lookupErr != nil {
186+ return fmt.Errorf("failed to look up repo by DID %s: %w", record.RepoDid, lookupErr)
187 }
188+ repo := *r
189190 ref := plumbing.ReferenceName(record.Ref)
191 if !ref.IsBranch() {
···200201 langs = append(langs, models.RepoLanguage{
202 RepoAt: repo.RepoAt(),
203+ RepoDid: repo.RepoDid,
204 Ref: ref.Short(),
205 IsDefaultRef: record.Meta.IsDefaultRef,
206 Language: l.Lang,
···239 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
240 }
241242+ if record.TriggerMetadata.Repo.RepoDid == "" {
243+ return fmt.Errorf("pipeline missing repoDid: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
0000000244 }
245+246+ repo, lookupErr := db.GetRepoByDid(d, record.TriggerMetadata.Repo.RepoDid)
247+ if lookupErr != nil {
248+ return fmt.Errorf("failed to look up repo by DID %s: %w", record.TriggerMetadata.Repo.RepoDid, lookupErr)
249 }
250+ repos := []models.Repo{*repo}
251 if repos[0].Spindle == "" {
252 return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey)
253 }
···285 Knot: source.Key(),
286 RepoOwner: syntax.DID(record.TriggerMetadata.Repo.Did),
287 RepoName: record.TriggerMetadata.Repo.Repo,
288+ RepoDid: repos[0].RepoDid,
289 TriggerId: int(triggerId),
290 Sha: sha,
291 }
+28-2
appview/state/router.go
···1package state
23import (
0004 "net/http"
5 "strings"
67 "github.com/go-chi/chi/v5"
08 "tangled.org/core/appview/issues"
9 "tangled.org/core/appview/knots"
10 "tangled.org/core/appview/labels"
···45 if len(pathParts) > 0 {
46 firstPart := pathParts[0]
4748- // if using a DID or handle, just continue as per usual
49- if userutil.IsDid(firstPart) || userutil.IsHandle(firstPart) {
000000000000000000000050 userRouter.ServeHTTP(w, r)
51 return
52 }
···60 "maxGraphemes": 40,
61 "maxLength": 400
62 },
00000000063 "pinnedRepositories": {
64 "type": "array",
65 "description": "Any ATURI, it is up to appviews to validate these fields.",
···60 "maxGraphemes": 40,
61 "maxLength": 400
62 },
63+ "pinnedRepositoryDids": {
64+ "type": "array",
65+ "minLength": 0,
66+ "maxLength": 6,
67+ "items": {
68+ "type": "string",
69+ "format": "did"
70+ }
71+ },
72 "pinnedRepositories": {
73 "type": "array",
74 "description": "Any ATURI, it is up to appviews to validate these fields.",
···21 "format": "at-uri",
22 "description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
23 },
000024 "performedAt": {
25 "type": "string",
26 "format": "datetime"
···21 "format": "at-uri",
22 "description": "The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."
23 },
24+ "subjectDid": {
25+ "type": "string",
26+ "format": "did"
27+ },
28 "performedAt": {
29 "type": "string",
30 "format": "datetime"