+79
-20
api/tangled/cbor_gen.go
+79
-20
api/tangled/cbor_gen.go
···
7934
7934
}
7935
7935
7936
7936
cw := cbg.NewCborWriter(w)
7937
-
fieldCount := 9
7937
+
fieldCount := 10
7938
7938
7939
7939
if t.Body == nil {
7940
7940
fieldCount--
7941
7941
}
7942
7942
7943
7943
if t.Mentions == nil {
7944
+
fieldCount--
7945
+
}
7946
+
7947
+
if t.Patch == nil {
7944
7948
fieldCount--
7945
7949
}
7946
7950
···
8008
8012
}
8009
8013
8010
8014
// t.Patch (string) (string)
8011
-
if len("patch") > 1000000 {
8012
-
return xerrors.Errorf("Value in field \"patch\" was too long")
8013
-
}
8015
+
if t.Patch != nil {
8014
8016
8015
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8016
-
return err
8017
-
}
8018
-
if _, err := cw.WriteString(string("patch")); err != nil {
8019
-
return err
8020
-
}
8017
+
if len("patch") > 1000000 {
8018
+
return xerrors.Errorf("Value in field \"patch\" was too long")
8019
+
}
8021
8020
8022
-
if len(t.Patch) > 1000000 {
8023
-
return xerrors.Errorf("Value in field t.Patch was too long")
8024
-
}
8021
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patch"))); err != nil {
8022
+
return err
8023
+
}
8024
+
if _, err := cw.WriteString(string("patch")); err != nil {
8025
+
return err
8026
+
}
8027
+
8028
+
if t.Patch == nil {
8029
+
if _, err := cw.Write(cbg.CborNull); err != nil {
8030
+
return err
8031
+
}
8032
+
} else {
8033
+
if len(*t.Patch) > 1000000 {
8034
+
return xerrors.Errorf("Value in field t.Patch was too long")
8035
+
}
8025
8036
8026
-
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Patch))); err != nil {
8027
-
return err
8028
-
}
8029
-
if _, err := cw.WriteString(string(t.Patch)); err != nil {
8030
-
return err
8037
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Patch))); err != nil {
8038
+
return err
8039
+
}
8040
+
if _, err := cw.WriteString(string(*t.Patch)); err != nil {
8041
+
return err
8042
+
}
8043
+
}
8031
8044
}
8032
8045
8033
8046
// t.Title (string) (string)
···
8147
8160
return err
8148
8161
}
8149
8162
8163
+
// t.PatchBlob (util.LexBlob) (struct)
8164
+
if len("patchBlob") > 1000000 {
8165
+
return xerrors.Errorf("Value in field \"patchBlob\" was too long")
8166
+
}
8167
+
8168
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("patchBlob"))); err != nil {
8169
+
return err
8170
+
}
8171
+
if _, err := cw.WriteString(string("patchBlob")); err != nil {
8172
+
return err
8173
+
}
8174
+
8175
+
if err := t.PatchBlob.MarshalCBOR(cw); err != nil {
8176
+
return err
8177
+
}
8178
+
8150
8179
// t.References ([]string) (slice)
8151
8180
if t.References != nil {
8152
8181
···
8262
8291
case "patch":
8263
8292
8264
8293
{
8265
-
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8294
+
b, err := cr.ReadByte()
8266
8295
if err != nil {
8267
8296
return err
8268
8297
}
8298
+
if b != cbg.CborNull[0] {
8299
+
if err := cr.UnreadByte(); err != nil {
8300
+
return err
8301
+
}
8269
8302
8270
-
t.Patch = string(sval)
8303
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
8304
+
if err != nil {
8305
+
return err
8306
+
}
8307
+
8308
+
t.Patch = (*string)(&sval)
8309
+
}
8271
8310
}
8272
8311
// t.Title (string) (string)
8273
8312
case "title":
···
8370
8409
}
8371
8410
8372
8411
t.CreatedAt = string(sval)
8412
+
}
8413
+
// t.PatchBlob (util.LexBlob) (struct)
8414
+
case "patchBlob":
8415
+
8416
+
{
8417
+
8418
+
b, err := cr.ReadByte()
8419
+
if err != nil {
8420
+
return err
8421
+
}
8422
+
if b != cbg.CborNull[0] {
8423
+
if err := cr.UnreadByte(); err != nil {
8424
+
return err
8425
+
}
8426
+
t.PatchBlob = new(util.LexBlob)
8427
+
if err := t.PatchBlob.UnmarshalCBOR(cr); err != nil {
8428
+
return xerrors.Errorf("unmarshaling t.PatchBlob pointer: %w", err)
8429
+
}
8430
+
}
8431
+
8373
8432
}
8374
8433
// t.References ([]string) (slice)
8375
8434
case "references":
+51
api/tangled/repolistRepos.go
+51
api/tangled/repolistRepos.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.listRepos
6
+
7
+
import (
8
+
"context"
9
+
10
+
"github.com/bluesky-social/indigo/lex/util"
11
+
)
12
+
13
+
const (
14
+
RepoListReposNSID = "sh.tangled.repo.listRepos"
15
+
)
16
+
17
+
// RepoListRepos_Output is the output of a sh.tangled.repo.listRepos call.
18
+
type RepoListRepos_Output struct {
19
+
Users []*RepoListRepos_User `json:"users" cborgen:"users"`
20
+
}
21
+
22
+
// RepoListRepos_RepoEntry is a "repoEntry" in the sh.tangled.repo.listRepos schema.
23
+
type RepoListRepos_RepoEntry struct {
24
+
// defaultBranch: Default branch of the repository
25
+
DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"`
26
+
// did: DID of the repository owner
27
+
Did string `json:"did" cborgen:"did"`
28
+
// fullPath: Full path to the repository
29
+
FullPath string `json:"fullPath" cborgen:"fullPath"`
30
+
// name: Repository name
31
+
Name string `json:"name" cborgen:"name"`
32
+
}
33
+
34
+
// RepoListRepos_User is a "user" in the sh.tangled.repo.listRepos schema.
35
+
type RepoListRepos_User struct {
36
+
// did: DID of the user
37
+
Did string `json:"did" cborgen:"did"`
38
+
Repos []*RepoListRepos_RepoEntry `json:"repos" cborgen:"repos"`
39
+
}
40
+
41
+
// RepoListRepos calls the XRPC method "sh.tangled.repo.listRepos".
42
+
func RepoListRepos(ctx context.Context, c util.LexClient) (*RepoListRepos_Output, error) {
43
+
var out RepoListRepos_Output
44
+
45
+
params := map[string]interface{}{}
46
+
if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listRepos", params, nil, &out); err != nil {
47
+
return nil, err
48
+
}
49
+
50
+
return &out, nil
51
+
}
+12
-9
api/tangled/repopull.go
+12
-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" cborgen:"createdAt"`
23
-
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
-
Patch string `json:"patch" cborgen:"patch"`
25
-
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
26
-
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
27
-
Target *RepoPull_Target `json:"target" cborgen:"target"`
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" cborgen:"createdAt"`
23
+
Mentions []string `json:"mentions,omitempty" cborgen:"mentions,omitempty"`
24
+
// patch: (deprecated) use patchBlob instead
25
+
Patch *string `json:"patch,omitempty" cborgen:"patch,omitempty"`
26
+
// patchBlob: patch content
27
+
PatchBlob *util.LexBlob `json:"patchBlob" cborgen:"patchBlob"`
28
+
References []string `json:"references,omitempty" cborgen:"references,omitempty"`
29
+
Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"`
30
+
Target *RepoPull_Target `json:"target" cborgen:"target"`
31
+
Title string `json:"title" cborgen:"title"`
29
32
}
30
33
31
34
// RepoPull_Source is a "source" in the sh.tangled.repo.pull schema.
+18
-11
appview/db/profile.go
+18
-11
appview/db/profile.go
···
20
20
timeline := models.ProfileTimeline{
21
21
ByMonth: make([]models.ByMonth, TimeframeMonths),
22
22
}
23
-
currentMonth := time.Now().Month()
23
+
now := time.Now()
24
24
timeframe := fmt.Sprintf("-%d months", TimeframeMonths)
25
25
26
26
pulls, err := GetPullsByOwnerDid(e, forDid, timeframe)
···
30
30
31
31
// group pulls by month
32
32
for _, pull := range pulls {
33
-
pullMonth := pull.Created.Month()
33
+
monthsAgo := monthsBetween(pull.Created, now)
34
34
35
-
if currentMonth-pullMonth >= TimeframeMonths {
35
+
if monthsAgo >= TimeframeMonths {
36
36
// shouldn't happen; but times are weird
37
37
continue
38
38
}
39
39
40
-
idx := currentMonth - pullMonth
40
+
idx := monthsAgo
41
41
items := &timeline.ByMonth[idx].PullEvents.Items
42
42
43
43
*items = append(*items, &pull)
···
53
53
}
54
54
55
55
for _, issue := range issues {
56
-
issueMonth := issue.Created.Month()
56
+
monthsAgo := monthsBetween(issue.Created, now)
57
57
58
-
if currentMonth-issueMonth >= TimeframeMonths {
58
+
if monthsAgo >= TimeframeMonths {
59
59
// shouldn't happen; but times are weird
60
60
continue
61
61
}
62
62
63
-
idx := currentMonth - issueMonth
63
+
idx := monthsAgo
64
64
items := &timeline.ByMonth[idx].IssueEvents.Items
65
65
66
66
*items = append(*items, &issue)
···
77
77
if repo.Source != "" {
78
78
sourceRepo, err = GetRepoByAtUri(e, repo.Source)
79
79
if err != nil {
80
-
return nil, err
80
+
// the source repo was not found, skip this bit
81
+
log.Println("profile", "err", err)
81
82
}
82
83
}
83
84
84
-
repoMonth := repo.Created.Month()
85
+
monthsAgo := monthsBetween(repo.Created, now)
85
86
86
-
if currentMonth-repoMonth >= TimeframeMonths {
87
+
if monthsAgo >= TimeframeMonths {
87
88
// shouldn't happen; but times are weird
88
89
continue
89
90
}
90
91
91
-
idx := currentMonth - repoMonth
92
+
idx := monthsAgo
92
93
93
94
items := &timeline.ByMonth[idx].RepoEvents
94
95
*items = append(*items, models.RepoEvent{
···
98
99
}
99
100
100
101
return &timeline, nil
102
+
}
103
+
104
+
func monthsBetween(from, to time.Time) int {
105
+
years := to.Year() - from.Year()
106
+
months := int(to.Month() - from.Month())
107
+
return years*12 + months
101
108
}
102
109
103
110
func UpsertProfile(tx *sql.Tx, profile *models.Profile) error {
+1
-1
appview/db/punchcard.go
+1
-1
appview/db/punchcard.go
-5
appview/knots/knots.go
-5
appview/knots/knots.go
···
666
666
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
667
667
return
668
668
}
669
-
if memberId.Handle.IsInvalidHandle() {
670
-
l.Error("failed to resolve member identity to handle")
671
-
k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
672
-
return
673
-
}
674
669
675
670
// remove from enforcer
676
671
err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String())
+4
appview/middleware/middleware.go
+4
appview/middleware/middleware.go
···
223
223
)
224
224
if err != nil {
225
225
log.Println("failed to resolve repo", "err", err)
226
+
w.WriteHeader(http.StatusNotFound)
226
227
mw.pages.ErrorKnot404(w)
227
228
return
228
229
}
···
240
241
f, err := mw.repoResolver.Resolve(r)
241
242
if err != nil {
242
243
log.Println("failed to fully resolve repo", err)
244
+
w.WriteHeader(http.StatusNotFound)
243
245
mw.pages.ErrorKnot404(w)
244
246
return
245
247
}
···
288
290
f, err := mw.repoResolver.Resolve(r)
289
291
if err != nil {
290
292
log.Println("failed to fully resolve repo", err)
293
+
w.WriteHeader(http.StatusNotFound)
291
294
mw.pages.ErrorKnot404(w)
292
295
return
293
296
}
···
324
327
f, err := mw.repoResolver.Resolve(r)
325
328
if err != nil {
326
329
log.Println("failed to fully resolve repo", err)
330
+
w.WriteHeader(http.StatusNotFound)
327
331
mw.pages.ErrorKnot404(w)
328
332
return
329
333
}
+1
-1
appview/models/pull.go
+1
-1
appview/models/pull.go
···
83
83
Repo *Repo
84
84
}
85
85
86
+
// NOTE: This method does not include patch blob in returned atproto record
86
87
func (p Pull) AsRecord() tangled.RepoPull {
87
88
var source *tangled.RepoPull_Source
88
89
if p.PullSource != nil {
···
113
114
Repo: p.RepoAt.String(),
114
115
Branch: p.TargetBranch,
115
116
},
116
-
Patch: p.LatestPatch(),
117
117
Source: source,
118
118
}
119
119
return record
+1
-1
appview/pages/templates/repo/fragments/diff.html
+1
-1
appview/pages/templates/repo/fragments/diff.html
···
17
17
{{ else }}
18
18
{{ range $idx, $hunk := $diff }}
19
19
{{ with $hunk }}
20
-
<details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
20
+
<details open id="file-{{ .Id }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}">
21
21
<summary class="list-none cursor-pointer sticky top-0">
22
22
<div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between">
23
23
<div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto">
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
+35
-35
appview/pages/templates/repo/fragments/splitDiff.html
···
3
3
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}}
4
4
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
5
5
{{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
6
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
6
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
7
7
{{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}}
8
8
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}}
9
9
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
10
10
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
11
11
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
12
12
<div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700">
13
-
<pre class="overflow-x-auto col-span-1"><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">···</div>
13
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
14
14
{{- range .LeftLines -}}
15
15
{{- if .IsEmpty -}}
16
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
17
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
18
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
19
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
20
-
</div>
16
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
17
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
18
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
19
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
20
+
</span>
21
21
{{- else if eq .Op.String "-" -}}
22
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
24
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
25
-
<div class="px-2">{{ .Content }}</div>
26
-
</div>
22
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
23
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
24
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
25
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
26
+
</span>
27
27
{{- else if eq .Op.String " " -}}
28
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
-
<div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div>
30
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
31
-
<div class="px-2">{{ .Content }}</div>
32
-
</div>
28
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}">
29
+
<span class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></span>
30
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
31
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
32
+
</span>
33
33
{{- end -}}
34
34
{{- end -}}
35
-
{{- end -}}</div></div></pre>
35
+
{{- end -}}</div></div></div>
36
36
37
-
<pre class="overflow-x-auto col-span-1"><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">···</div>
37
+
<div class="overflow-x-auto col-span-1 font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
38
38
{{- range .RightLines -}}
39
39
{{- if .IsEmpty -}}
40
-
<div class="{{ $emptyStyle }} {{ $containerStyle }}">
41
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div>
42
-
<div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div>
43
-
<div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div>
44
-
</div>
40
+
<span class="{{ $emptyStyle }} {{ $containerStyle }}">
41
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></span>
42
+
<span class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></span>
43
+
<span class="px-2 invisible" aria-hidden="true">{{ .Content }}</span>
44
+
</span>
45
45
{{- else if eq .Op.String "+" -}}
46
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
48
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
49
-
<div class="px-2" >{{ .Content }}</div>
50
-
</div>
46
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
47
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></span>
48
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
49
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
50
+
</span>
51
51
{{- else if eq .Op.String " " -}}
52
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div>
54
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
55
-
<div class="px-2">{{ .Content }}</div>
56
-
</div>
52
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}">
53
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a> </span>
54
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
55
+
<span class="px-2 whitespace-pre">{{ .Content }}</span>
56
+
</span>
57
57
{{- end -}}
58
58
{{- end -}}
59
-
{{- end -}}</div></div></pre>
59
+
{{- end -}}</div></div></div>
60
60
</div>
61
61
{{ end }}
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
+21
-22
appview/pages/templates/repo/fragments/unifiedDiff.html
···
1
1
{{ define "repo/fragments/unifiedDiff" }}
2
2
{{ $name := .Id }}
3
-
<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">···</div>
3
+
<div class="overflow-x-auto font-mono leading-normal"><div class="overflow-x-auto"><div class="inline-flex flex-col min-w-full">{{- range .TextFragments -}}<span class="block bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">···</span>
4
4
{{- $oldStart := .OldPosition -}}
5
5
{{- $newStart := .NewPosition -}}
6
6
{{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}}
7
7
{{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}}
8
8
{{- $lineNrSepStyle1 := "" -}}
9
9
{{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}}
10
-
{{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
10
+
{{- $containerStyle := "inline-flex w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}}
11
11
{{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}}
12
12
{{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}}
13
13
{{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}}
14
14
{{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}}
15
15
{{- range .Lines -}}
16
16
{{- if eq .Op.String "+" -}}
17
-
<div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div>
19
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div>
20
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
21
-
<div class="px-2">{{ .Line }}</div>
22
-
</div>
17
+
<span class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}">
18
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></span>
19
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></span>
20
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
21
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
22
+
</span>
23
23
{{- $newStart = add64 $newStart 1 -}}
24
24
{{- end -}}
25
25
{{- if eq .Op.String "-" -}}
26
-
<div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div>
28
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div>
29
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
30
-
<div class="px-2">{{ .Line }}</div>
31
-
</div>
26
+
<span class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}">
27
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></span>
28
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></span>
29
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
30
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
31
+
</span>
32
32
{{- $oldStart = add64 $oldStart 1 -}}
33
33
{{- end -}}
34
34
{{- if eq .Op.String " " -}}
35
-
<div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div>
37
-
<div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div>
38
-
<div class="{{ $opStyle }}">{{ .Op.String }}</div>
39
-
<div class="px-2">{{ .Line }}</div>
40
-
</div>
35
+
<span class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}">
36
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></span>
37
+
<span class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></span>
38
+
<span class="{{ $opStyle }}">{{ .Op.String }}</span>
39
+
<span class="px-2 whitespace-pre">{{ .Line }}</span>
40
+
</span>
41
41
{{- $newStart = add64 $newStart 1 -}}
42
42
{{- $oldStart = add64 $oldStart 1 -}}
43
43
{{- end -}}
44
44
{{- end -}}
45
-
{{- end -}}</div></div></pre>
45
+
{{- end -}}</div></div></div>
46
46
{{ end }}
47
-
+48
-36
appview/pulls/pulls.go
+48
-36
appview/pulls/pulls.go
···
1241
1241
return
1242
1242
}
1243
1243
1244
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1245
+
if err != nil {
1246
+
log.Println("failed to upload patch", err)
1247
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1248
+
return
1249
+
}
1250
+
1244
1251
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1245
1252
Collection: tangled.RepoPullNSID,
1246
1253
Repo: user.Did,
···
1252
1259
Repo: string(repo.RepoAt()),
1253
1260
Branch: targetBranch,
1254
1261
},
1255
-
Patch: patch,
1262
+
PatchBlob: blob.Blob,
1256
1263
Source: recordPullSource,
1257
1264
CreatedAt: time.Now().Format(time.RFC3339),
1258
1265
},
···
1328
1335
// apply all record creations at once
1329
1336
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1330
1337
for _, p := range stack {
1338
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(p.LatestPatch()))
1339
+
if err != nil {
1340
+
log.Println("failed to upload patch blob", err)
1341
+
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1342
+
return
1343
+
}
1344
+
1331
1345
record := p.AsRecord()
1332
-
write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1346
+
record.PatchBlob = blob.Blob
1347
+
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1333
1348
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1334
1349
Collection: tangled.RepoPullNSID,
1335
1350
Rkey: &p.Rkey,
···
1337
1352
Val: &record,
1338
1353
},
1339
1354
},
1340
-
}
1341
-
writes = append(writes, &write)
1355
+
})
1342
1356
}
1343
1357
_, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1344
1358
Repo: user.Did,
···
1871
1885
return
1872
1886
}
1873
1887
1874
-
var recordPullSource *tangled.RepoPull_Source
1875
-
if pull.IsBranchBased() {
1876
-
recordPullSource = &tangled.RepoPull_Source{
1877
-
Branch: pull.PullSource.Branch,
1878
-
Sha: sourceRev,
1879
-
}
1888
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1889
+
if err != nil {
1890
+
log.Println("failed to upload patch blob", err)
1891
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1892
+
return
1880
1893
}
1881
-
if pull.IsForkBased() {
1882
-
repoAt := pull.PullSource.RepoAt.String()
1883
-
recordPullSource = &tangled.RepoPull_Source{
1884
-
Branch: pull.PullSource.Branch,
1885
-
Repo: &repoAt,
1886
-
Sha: sourceRev,
1887
-
}
1888
-
}
1894
+
record := pull.AsRecord()
1895
+
record.PatchBlob = blob.Blob
1896
+
record.CreatedAt = time.Now().Format(time.RFC3339)
1889
1897
1890
1898
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1891
1899
Collection: tangled.RepoPullNSID,
···
1893
1901
Rkey: pull.Rkey,
1894
1902
SwapRecord: ex.Cid,
1895
1903
Record: &lexutil.LexiconTypeDecoder{
1896
-
Val: &tangled.RepoPull{
1897
-
Title: pull.Title,
1898
-
Target: &tangled.RepoPull_Target{
1899
-
Repo: string(repo.RepoAt()),
1900
-
Branch: pull.TargetBranch,
1901
-
},
1902
-
Patch: patch, // new patch
1903
-
Source: recordPullSource,
1904
-
CreatedAt: time.Now().Format(time.RFC3339),
1905
-
},
1904
+
Val: &record,
1906
1905
},
1907
1906
})
1908
1907
if err != nil {
···
1988
1987
}
1989
1988
defer tx.Rollback()
1990
1989
1990
+
client, err := s.oauth.AuthorizedClient(r)
1991
+
if err != nil {
1992
+
log.Println("failed to authorize client")
1993
+
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1994
+
return
1995
+
}
1996
+
1991
1997
// pds updates to make
1992
1998
var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1993
1999
···
2021
2027
return
2022
2028
}
2023
2029
2030
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2031
+
if err != nil {
2032
+
log.Println("failed to upload patch blob", err)
2033
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2034
+
return
2035
+
}
2024
2036
record := p.AsRecord()
2037
+
record.PatchBlob = blob.Blob
2025
2038
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2026
2039
RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2027
2040
Collection: tangled.RepoPullNSID,
···
2056
2069
return
2057
2070
}
2058
2071
2072
+
blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2073
+
if err != nil {
2074
+
log.Println("failed to upload patch blob", err)
2075
+
s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2076
+
return
2077
+
}
2059
2078
record := np.AsRecord()
2060
-
2079
+
record.PatchBlob = blob.Blob
2061
2080
writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2062
2081
RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2063
2082
Collection: tangled.RepoPullNSID,
···
2091
2110
if err != nil {
2092
2111
log.Println("failed to resubmit pull", err)
2093
2112
s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2094
-
return
2095
-
}
2096
-
2097
-
client, err := s.oauth.AuthorizedClient(r)
2098
-
if err != nil {
2099
-
log.Println("failed to authorize client")
2100
-
s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2101
2113
return
2102
2114
}
2103
2115
+1
appview/repo/archive.go
+1
appview/repo/archive.go
···
18
18
l := rp.logger.With("handler", "DownloadArchive")
19
19
ref := chi.URLParam(r, "ref")
20
20
ref, _ = url.PathUnescape(ref)
21
+
ref = strings.TrimSuffix(ref, ".tar.gz")
21
22
f, err := rp.repoResolver.Resolve(r)
22
23
if err != nil {
23
24
l.Error("failed to get repo and knot", "err", err)
-5
appview/spindles/spindles.go
-5
appview/spindles/spindles.go
···
653
653
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
654
654
return
655
655
}
656
-
if memberId.Handle.IsInvalidHandle() {
657
-
l.Error("failed to resolve member identity to handle")
658
-
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
659
-
return
660
-
}
661
656
662
657
tx, err := s.Db.Begin()
663
658
if err != nil {
+6
-4
appview/state/profile.go
+6
-4
appview/state/profile.go
···
163
163
}
164
164
165
165
// populate commit counts in the timeline, using the punchcard
166
-
currentMonth := time.Now().Month()
166
+
now := time.Now()
167
167
for _, p := range profile.Punchcard.Punches {
168
-
idx := currentMonth - p.Date.Month()
169
-
if int(idx) < len(timeline.ByMonth) {
170
-
timeline.ByMonth[idx].Commits += p.Count
168
+
years := now.Year() - p.Date.Year()
169
+
months := int(now.Month() - p.Date.Month())
170
+
monthsAgo := years*12 + months
171
+
if monthsAgo >= 0 && monthsAgo < len(timeline.ByMonth) {
172
+
timeline.ByMonth[monthsAgo].Commits += p.Count
171
173
}
172
174
}
173
175
+2
appview/state/router.go
+2
appview/state/router.go
···
109
109
})
110
110
111
111
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
112
+
w.WriteHeader(http.StatusNotFound)
112
113
s.pages.Error404(w)
113
114
})
114
115
···
182
183
r.Get("/brand", s.Brand)
183
184
184
185
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
186
+
w.WriteHeader(http.StatusNotFound)
185
187
s.pages.Error404(w)
186
188
})
187
189
return r
+65
-66
docs/DOCS.md
+65
-66
docs/DOCS.md
···
1
1
---
2
-
title: Tangled Documentation
2
+
title: Tangled docs
3
3
author: The Tangled Contributors
4
4
date: 21 Sun, Dec 2025
5
5
---
···
8
8
9
9
Tangled is a decentralized code hosting and collaboration
10
10
platform. Every component of Tangled is open-source and
11
-
selfhostable. [tangled.org](https://tangled.org) also
11
+
self-hostable. [tangled.org](https://tangled.org) also
12
12
provides hosting and CI services that are free to use.
13
13
14
14
There are several models for decentralized code
15
15
collaboration platforms, ranging from ActivityPub’s
16
16
(Forgejo) federated model, to Radicle’s entirely P2P model.
17
17
Our approach attempts to be the best of both worlds by
18
-
adopting atproto—a protocol for building decentralized
18
+
adopting the AT Protocol—a protocol for building decentralized
19
19
social applications with a central identity
20
20
21
21
Our approach to this is the idea of “knots”. Knots are
···
26
26
default, Tangled provides managed knots where you can host
27
27
your repositories for free.
28
28
29
-
The "appview" at tangled.org acts as a consolidated “view”
29
+
The appview at tangled.org acts as a consolidated "view"
30
30
into the whole network, allowing users to access, clone and
31
31
contribute to repositories hosted across different knots
32
32
seamlessly.
33
33
34
-
# Quick Start Guide
34
+
# Quick start guide
35
35
36
-
## Login or Sign up
36
+
## Login or sign up
37
37
38
-
You can [login](https://tangled.org) by using your AT
38
+
You can [login](https://tangled.org) by using your AT Protocol
39
39
account. If you are unclear on what that means, simply head
40
40
to the [signup](https://tangled.org/signup) page and create
41
41
an account. By doing so, you will be choosing Tangled as
42
42
your account provider (you will be granted a handle of the
43
43
form `user.tngl.sh`).
44
44
45
-
In the AT network, users are free to choose their account
45
+
In the AT Protocol network, users are free to choose their account
46
46
provider (known as a "Personal Data Service", or PDS), and
47
47
login to applications that support AT accounts.
48
48
49
-
You can think of it as "one account for all of the
50
-
atmosphere"!
49
+
You can think of it as "one account for all of the atmosphere"!
51
50
52
51
If you already have an AT account (you may have one if you
53
52
signed up to Bluesky, for example), you can login with the
54
53
same handle on Tangled (so just use `user.bsky.social` on
55
54
the login page).
56
55
57
-
## Add an SSH Key
56
+
## Add an SSH key
58
57
59
58
Once you are logged in, you can start creating repositories
60
59
and pushing code. Tangled supports pushing git repositories
···
87
86
paste your public key, give it a descriptive name, and hit
88
87
save.
89
88
90
-
## Create a Repository
89
+
## Create a repository
91
90
92
91
Once your SSH key is added, create your first repository:
93
92
···
98
97
4. Choose a knotserver to host this repository on
99
98
5. Hit create
100
99
101
-
"Knots" are selfhostable, lightweight git servers that can
100
+
Knots are self-hostable, lightweight Git servers that can
102
101
host your repository. Unlike traditional code forges, your
103
102
code can live on any server. Read the [Knots](TODO) section
104
103
for more.
···
125
124
are hosted by tangled.org. If you use a custom knot, refer
126
125
to the [Knots](TODO) section.
127
126
128
-
## Push Your First Repository
127
+
## Push your first repository
129
128
130
-
Initialize a new git repository:
129
+
Initialize a new Git repository:
131
130
132
131
```bash
133
132
mkdir my-project
···
165
164
cd /path/to/your/existing/repo
166
165
```
167
166
168
-
You can inspect your existing git remote like so:
167
+
You can inspect your existing Git remote like so:
169
168
170
169
```bash
171
170
git remote -v
···
197
196
origin git@tangled.org:user.tngl.sh/my-project (push)
198
197
```
199
198
200
-
Push all your branches and tags to tangled:
199
+
Push all your branches and tags to Tangled:
201
200
202
201
```bash
203
202
git push -u origin --all
···
232
231
```
233
232
234
233
You also need to re-add the original URL as a push
235
-
destination (git replaces the push URL when you use `--add`
234
+
destination (Git replaces the push URL when you use `--add`
236
235
the first time):
237
236
238
237
```bash
···
249
248
```
250
249
251
250
Notice that there's one fetch URL (the primary remote) and
252
-
two push URLs. Now, whenever you push, git will
251
+
two push URLs. Now, whenever you push, Git will
253
252
automatically push to both remotes:
254
253
255
254
```bash
···
301
300
## Docker
302
301
303
302
Refer to
304
-
[@tangled.org/knot-docker](https://tangled.sh/@tangled.sh/knot-docker).
303
+
[@tangled.org/knot-docker](https://tangled.org/@tangled.org/knot-docker).
305
304
Note that this is community maintained.
306
305
307
306
## Manual setup
···
372
371
```
373
372
KNOT_REPO_SCAN_PATH=/home/git
374
373
KNOT_SERVER_HOSTNAME=knot.example.com
375
-
APPVIEW_ENDPOINT=https://tangled.sh
374
+
APPVIEW_ENDPOINT=https://tangled.org
376
375
KNOT_SERVER_OWNER=did:plc:foobar
377
376
KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444
378
377
KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
···
603
602
- `nixery`: This uses an instance of
604
603
[Nixery](https://nixery.dev) to run steps, which allows
605
604
you to add [dependencies](#dependencies) from
606
-
[Nixpkgs](https://github.com/NixOS/nixpkgs). You can
605
+
Nixpkgs (https://github.com/NixOS/nixpkgs). You can
607
606
search for packages on https://search.nixos.org, and
608
607
there's a pretty good chance the package(s) you're looking
609
608
for will be there.
···
630
629
default, the depth is set to 1, meaning only the most
631
630
recent commit will be fetched, which is the commit that
632
631
triggered the workflow.
633
-
- `submodules`: If you use [git
634
-
submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules)
632
+
- `submodules`: If you use Git submodules
633
+
(https://git-scm.com/book/en/v2/Git-Tools-Submodules)
635
634
in your repository, setting this field to `true` will
636
635
recursively fetch all submodules. This is `false` by
637
636
default.
···
657
656
Say you want to fetch Node.js and Go from `nixpkgs`, and a
658
657
package called `my_pkg` you've made from your own registry
659
658
at your repository at
660
-
`https://tangled.sh/@example.com/my_pkg`. You can define
659
+
`https://tangled.org/@example.com/my_pkg`. You can define
661
660
those dependencies like so:
662
661
663
662
```yaml
···
779
778
780
779
If you want another example of a workflow, you can look at
781
780
the one [Tangled uses to build the
782
-
project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
781
+
project](https://tangled.org/@tangled.org/core/blob/master/.tangled/workflows/build.yml).
783
782
784
783
## Self-hosting guide
785
784
···
836
835
837
836
## Architecture
838
837
839
-
Spindle is a small CI runner service. Here's a high level overview of how it operates:
838
+
Spindle is a small CI runner service. Here's a high-level overview of how it operates:
840
839
841
-
* listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
840
+
* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
842
841
[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
843
-
* when a new repo record comes through (typically when you add a spindle to a
842
+
* When a new repo record comes through (typically when you add a spindle to a
844
843
repo from the settings), spindle then resolves the underlying knot and
845
844
subscribes to repo events (see:
846
845
[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
847
-
* the spindle engine then handles execution of the pipeline, with results and
848
-
logs beamed on the spindle event stream over wss
846
+
* The spindle engine then handles execution of the pipeline, with results and
847
+
logs beamed on the spindle event stream over WebSocket
849
848
850
849
### The engine
851
850
852
851
At present, the only supported backend is Docker (and Podman, if Docker
853
-
compatibility is enabled, so that `/run/docker.sock` is created). Spindle
852
+
compatibility is enabled, so that `/run/docker.sock` is created). spindle
854
853
executes each step in the pipeline in a fresh container, with state persisted
855
854
across steps within the `/tangled/workspace` directory.
856
855
···
862
861
863
862
## Secrets with openbao
864
863
865
-
This document covers setting up Spindle to use OpenBao for secrets
864
+
This document covers setting up spindle to use OpenBao for secrets
866
865
management via OpenBao Proxy instead of the default SQLite backend.
867
866
868
867
### Overview
869
868
870
869
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
871
-
authentication automatically using AppRole credentials, while Spindle
870
+
authentication automatically using AppRole credentials, while spindle
872
871
connects to the local proxy instead of directly to the OpenBao server.
873
872
874
873
This approach provides better security, automatic token renewal, and
···
876
875
877
876
### Installation
878
877
879
-
Install OpenBao from nixpkgs:
878
+
Install OpenBao from Nixpkgs:
880
879
881
880
```bash
882
881
nix shell nixpkgs#openbao # for a local server
···
1029
1028
}
1030
1029
}
1031
1030
1032
-
# Proxy listener for Spindle
1031
+
# Proxy listener for spindle
1033
1032
listener "tcp" {
1034
1033
address = "127.0.0.1:8201"
1035
1034
tls_disable = true
···
1062
1061
1063
1062
#### Configure spindle
1064
1063
1065
-
Set these environment variables for Spindle:
1064
+
Set these environment variables for spindle:
1066
1065
1067
1066
```bash
1068
1067
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
···
1070
1069
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
1071
1070
```
1072
1071
1073
-
On startup, the spindle will now connect to the local proxy,
1072
+
On startup, spindle will now connect to the local proxy,
1074
1073
which handles all authentication automatically.
1075
1074
1076
1075
### Production setup for proxy
···
1099
1098
# List all secrets
1100
1099
bao kv list spindle/
1101
1100
1102
-
# Add a test secret via Spindle API, then check it exists
1101
+
# Add a test secret via the spindle API, then check it exists
1103
1102
bao kv list spindle/repos/
1104
1103
1105
1104
# Get a specific secret
···
1112
1111
port 8200 or 8201)
1113
1112
- The proxy authenticates with OpenBao using AppRole
1114
1113
credentials
1115
-
- All Spindle requests go through the proxy, which injects
1114
+
- All spindle requests go through the proxy, which injects
1116
1115
authentication tokens
1117
1116
- Secrets are stored at
1118
1117
`spindle/repos/{sanitized_repo_path}/{secret_key}`
···
1131
1130
and the policy has the necessary permissions.
1132
1131
1133
1132
**404 route errors**: The spindle KV mount probably doesn't
1134
-
exist - run the mount creation step again.
1133
+
exist—run the mount creation step again.
1135
1134
1136
1135
**Proxy authentication failures**: Check the proxy logs and
1137
1136
verify the role-id and secret-id files are readable and
···
1159
1158
secret_id="$(cat /tmp/openbao/secret-id)"
1160
1159
```
1161
1160
1162
-
# Migrating knots & spindles
1161
+
# Migrating knots and spindles
1163
1162
1164
1163
Sometimes, non-backwards compatible changes are made to the
1165
1164
knot/spindle XRPC APIs. If you host a knot or a spindle, you
···
1172
1171
1173
1172
## Upgrading from v1.8.x
1174
1173
1175
-
After v1.8.2, the HTTP API for knot and spindles have been
1174
+
After v1.8.2, the HTTP API for knots and spindles has been
1176
1175
deprecated and replaced with XRPC. Repositories on outdated
1177
1176
knots will not be viewable from the appview. Upgrading is
1178
1177
straightforward however.
1179
1178
1180
1179
For knots:
1181
1180
1182
-
- Upgrade to latest tag (v1.9.0 or above)
1181
+
- Upgrade to the latest tag (v1.9.0 or above)
1183
1182
- Head to the [knot dashboard](https://tangled.org/settings/knots) and
1184
1183
hit the "retry" button to verify your knot
1185
1184
1186
1185
For spindles:
1187
1186
1188
-
- Upgrade to latest tag (v1.9.0 or above)
1187
+
- Upgrade to the latest tag (v1.9.0 or above)
1189
1188
- Head to the [spindle
1190
1189
dashboard](https://tangled.org/settings/spindles) and hit the
1191
1190
"retry" button to verify your spindle
···
1227
1226
# Hacking on Tangled
1228
1227
1229
1228
We highly recommend [installing
1230
-
nix](https://nixos.org/download/) (the package manager)
1231
-
before working on the codebase. The nix flake provides a lot
1229
+
Nix](https://nixos.org/download/) (the package manager)
1230
+
before working on the codebase. The Nix flake provides a lot
1232
1231
of helpers to get started and most importantly, builds and
1233
1232
dev shells are entirely deterministic.
1234
1233
···
1238
1237
nix develop
1239
1238
```
1240
1239
1241
-
Non-nix users can look at the `devShell` attribute in the
1240
+
Non-Nix users can look at the `devShell` attribute in the
1242
1241
`flake.nix` file to determine necessary dependencies.
1243
1242
1244
1243
## Running the appview
1245
1244
1246
-
The nix flake also exposes a few `app` attributes (run `nix
1245
+
The Nix flake also exposes a few `app` attributes (run `nix
1247
1246
flake show` to see a full list of what the flake provides),
1248
1247
one of the apps runs the appview with the `air`
1249
1248
live-reloader:
···
1258
1257
nix run .#watch-tailwind
1259
1258
```
1260
1259
1261
-
To authenticate with the appview, you will need redis and
1262
-
OAUTH JWKs to be setup:
1260
+
To authenticate with the appview, you will need Redis and
1261
+
OAuth JWKs to be set up:
1263
1262
1264
1263
```
1265
-
# oauth jwks should already be setup by the nix devshell:
1264
+
# OAuth JWKs should already be set up by the Nix devshell:
1266
1265
echo $TANGLED_OAUTH_CLIENT_SECRET
1267
1266
z42ty4RT1ovnTopY8B8ekz9NuziF2CuMkZ7rbRFpAR9jBqMc
1268
1267
···
1280
1279
# the secret key from above
1281
1280
export TANGLED_OAUTH_CLIENT_SECRET="z42tuP..."
1282
1281
1283
-
# run redis in at a new shell to store oauth sessions
1282
+
# Run Redis in a new shell to store OAuth sessions
1284
1283
redis-server
1285
1284
```
1286
1285
1287
1286
## Running knots and spindles
1288
1287
1289
1288
An end-to-end knot setup requires setting up a machine with
1290
-
`sshd`, `AuthorizedKeysCommand`, and git user, which is
1291
-
quite cumbersome. So the nix flake provides a
1289
+
`sshd`, `AuthorizedKeysCommand`, and a Git user, which is
1290
+
quite cumbersome. So the Nix flake provides a
1292
1291
`nixosConfiguration` to do so.
1293
1292
1294
1293
<details>
1295
-
<summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary>
1294
+
<summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1296
1295
1297
1296
In order to build Tangled's dev VM on macOS, you will
1298
1297
first need to set up a Linux Nix builder. The recommended
···
1303
1302
you are using Apple Silicon).
1304
1303
1305
1304
> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
1306
-
> the tangled repo so that it doesn't conflict with the other VM. For example,
1305
+
> the Tangled repo so that it doesn't conflict with the other VM. For example,
1307
1306
> you can do
1308
1307
>
1309
1308
> ```shell
···
1316
1315
> avoid subtle problems.
1317
1316
1318
1317
Alternatively, you can use any other method to set up a
1319
-
Linux machine with `nix` installed that you can `sudo ssh`
1318
+
Linux machine with Nix installed that you can `sudo ssh`
1320
1319
into (in other words, root user on your Mac has to be able
1321
1320
to ssh into the Linux machine without entering a password)
1322
1321
and that has the same architecture as your Mac. See
···
1347
1346
with `ssh` exposed on port 2222.
1348
1347
1349
1348
Once the services are running, head to
1350
-
http://localhost:3000/settings/knots and hit verify. It should
1349
+
http://localhost:3000/settings/knots and hit "Verify". It should
1351
1350
verify the ownership of the services instantly if everything
1352
1351
went smoothly.
1353
1352
···
1371
1370
1372
1371
The above VM should already be running a spindle on
1373
1372
`localhost:6555`. Head to http://localhost:3000/settings/spindles and
1374
-
hit verify. You can then configure each repository to use
1373
+
hit "Verify". You can then configure each repository to use
1375
1374
this spindle and run CI jobs.
1376
1375
1377
1376
Of interest when debugging spindles:
1378
1377
1379
1378
```
1380
-
# service logs from journald:
1379
+
# Service logs from journald:
1381
1380
journalctl -xeu spindle
1382
1381
1383
1382
# CI job logs from disk:
1384
1383
ls /var/log/spindle
1385
1384
1386
-
# debugging spindle db:
1385
+
# Debugging spindle database:
1387
1386
sqlite3 /var/lib/spindle/spindle.db
1388
1387
1389
1388
# litecli has a nicer REPL interface:
···
1432
1431
1433
1432
### General notes
1434
1433
1435
-
- PRs get merged "as-is" (fast-forward) -- like applying a patch-series
1436
-
using `git am`. At present, there is no squashing -- so please author
1434
+
- PRs get merged "as-is" (fast-forward)—like applying a patch-series
1435
+
using `git am`. At present, there is no squashing—so please author
1437
1436
your commits as they would appear on `master`, following the above
1438
1437
guidelines.
1439
1438
- If there is a lot of nesting, for example "appview:
···
1454
1453
## Code formatting
1455
1454
1456
1455
We use a variety of tools to format our code, and multiplex them with
1457
-
[`treefmt`](https://treefmt.com): all you need to do to format your changes
1456
+
[`treefmt`](https://treefmt.com). All you need to do to format your changes
1458
1457
is run `nix run .#fmt` (or just `treefmt` if you're in the devshell).
1459
1458
1460
1459
## Proposals for bigger changes
···
1482
1481
We'll use the issue thread to discuss and refine the idea before moving
1483
1482
forward.
1484
1483
1485
-
## Developer certificate of origin (DCO)
1484
+
## Developer Certificate of Origin (DCO)
1486
1485
1487
1486
We require all contributors to certify that they have the right to
1488
1487
submit the code they're contributing. To do this, we follow the
+33
-8
docs/template.html
+33
-8
docs/template.html
···
20
20
<meta name="description" content="$description-meta$" />
21
21
$endif$
22
22
23
-
<title>$pagetitle$ - Tangled docs</title>
23
+
<title>$pagetitle$</title>
24
24
25
25
<style>
26
26
$styles.css()$
···
43
43
$endfor$
44
44
45
45
$if(toc)$
46
-
<!-- mobile topbar toc -->
47
-
<details id="mobile-$idprefix$TOC" role="doc-toc" class="md:hidden bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 z-50 space-y-4 group px-6 py-4">
48
-
<summary class="cursor-pointer list-none text-sm font-semibold select-none flex gap-2 justify-between items-center dark:text-white">
46
+
<!-- mobile TOC trigger -->
47
+
<div class="md:hidden px-6 py-4 border-b border-gray-200 dark:border-gray-700">
48
+
<button
49
+
type="button"
50
+
popovertarget="mobile-toc-popover"
51
+
popovertargetaction="toggle"
52
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white"
53
+
>
54
+
${ menu.svg() }
55
+
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
56
+
</button>
57
+
</div>
58
+
59
+
<div
60
+
id="mobile-toc-popover"
61
+
popover
62
+
class="mobile-toc-popover
63
+
bg-white dark:bg-gray-800
64
+
border-b border-gray-200 dark:border-gray-700
65
+
h-full overflow-y-auto
66
+
px-6 py-4 fixed inset-x-0 top-0 w-fit max-w-4/5 m-0"
67
+
>
68
+
<button
69
+
type="button"
70
+
popovertarget="mobile-toc-popover"
71
+
popovertargetaction="toggle"
72
+
class="w-full flex gap-2 items-center text-sm font-semibold dark:text-white mb-4">
73
+
${ x.svg() }
49
74
$if(toc-title)$$toc-title$$else$Table of Contents$endif$
50
-
<span class="group-open:hidden inline">${ menu.svg() }</span>
51
-
<span class="hidden group-open:inline">${ x.svg() }</span>
52
-
</summary>
75
+
</button>
53
76
${ table-of-contents:toc.html() }
54
-
</details>
77
+
</div>
78
+
79
+
55
80
<!-- desktop sidebar toc -->
56
81
<nav id="$idprefix$TOC" role="doc-toc" class="hidden md:block fixed left-0 top-0 w-80 h-screen bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-4 z-50">
57
82
$if(toc-title)$
+1
-1
flake.nix
+1
-1
flake.nix
···
76
76
};
77
77
buildGoApplication =
78
78
(self.callPackage "${gomod2nix}/builder" {
79
-
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
79
+
gomod2nix = gomod2nix.legacyPackages.${pkgs.stdenv.hostPlatform.system}.gomod2nix;
80
80
}).buildGoApplication;
81
81
modules = ./nix/gomod2nix.toml;
82
82
sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix {
+1
input.css
+1
input.css
+103
knotserver/xrpc/list_repos.go
+103
knotserver/xrpc/list_repos.go
···
1
+
package xrpc
2
+
3
+
import (
4
+
"net/http"
5
+
"os"
6
+
"path/filepath"
7
+
"strings"
8
+
9
+
securejoin "github.com/cyphar/filepath-securejoin"
10
+
"tangled.org/core/api/tangled"
11
+
"tangled.org/core/knotserver/git"
12
+
xrpcerr "tangled.org/core/xrpc/errors"
13
+
)
14
+
15
+
// ListRepos lists all users (DIDs) and their repositories by scanning the repository directory
16
+
func (x *Xrpc) ListRepos(w http.ResponseWriter, r *http.Request) {
17
+
scanPath := x.Config.Repo.ScanPath
18
+
19
+
didEntries, err := os.ReadDir(scanPath)
20
+
if err != nil {
21
+
x.Logger.Error("failed to read scan path", "error", err, "path", scanPath)
22
+
writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
23
+
return
24
+
}
25
+
26
+
var users []*tangled.RepoListRepos_User
27
+
28
+
for _, didEntry := range didEntries {
29
+
if !didEntry.IsDir() {
30
+
continue
31
+
}
32
+
33
+
did := didEntry.Name()
34
+
35
+
// Validate DID format (basic check)
36
+
if !strings.HasPrefix(did, "did:") {
37
+
continue
38
+
}
39
+
40
+
didPath, err := securejoin.SecureJoin(scanPath, did)
41
+
if err != nil {
42
+
x.Logger.Warn("failed to join path for did", "did", did, "error", err)
43
+
continue
44
+
}
45
+
46
+
// Read repositories for this DID
47
+
repoEntries, err := os.ReadDir(didPath)
48
+
if err != nil {
49
+
x.Logger.Warn("failed to read did directory", "did", did, "error", err)
50
+
continue
51
+
}
52
+
53
+
var repos []*tangled.RepoListRepos_RepoEntry
54
+
55
+
for _, repoEntry := range repoEntries {
56
+
if !repoEntry.IsDir() {
57
+
continue
58
+
}
59
+
60
+
repoName := repoEntry.Name()
61
+
62
+
// Check if it's a valid git repository
63
+
repoPath, err := securejoin.SecureJoin(didPath, repoName)
64
+
if err != nil {
65
+
continue
66
+
}
67
+
68
+
repo, err := git.PlainOpen(repoPath)
69
+
if err != nil {
70
+
// Not a valid git repository, skip
71
+
continue
72
+
}
73
+
74
+
// Get default branch
75
+
defaultBranch := "master"
76
+
branch, err := repo.FindMainBranch()
77
+
if err == nil {
78
+
defaultBranch = branch
79
+
}
80
+
81
+
repos = append(repos, &tangled.RepoListRepos_RepoEntry{
82
+
Name: repoName,
83
+
Did: did,
84
+
FullPath: filepath.Join(did, repoName),
85
+
DefaultBranch: &defaultBranch,
86
+
})
87
+
}
88
+
89
+
// Only add user if they have repositories
90
+
if len(repos) > 0 {
91
+
users = append(users, &tangled.RepoListRepos_User{
92
+
Did: did,
93
+
Repos: repos,
94
+
})
95
+
}
96
+
}
97
+
98
+
response := tangled.RepoListRepos_Output{
99
+
Users: users,
100
+
}
101
+
102
+
writeJson(w, response)
103
+
}
+1
knotserver/xrpc/xrpc.go
+1
knotserver/xrpc/xrpc.go
+8
-2
lexicons/pulls/pull.json
+8
-2
lexicons/pulls/pull.json
···
12
12
"required": [
13
13
"target",
14
14
"title",
15
-
"patch",
15
+
"patchBlob",
16
16
"createdAt"
17
17
],
18
18
"properties": {
···
27
27
"type": "string"
28
28
},
29
29
"patch": {
30
-
"type": "string"
30
+
"type": "string",
31
+
"description": "(deprecated) use patchBlob instead"
32
+
},
33
+
"patchBlob": {
34
+
"type": "blob",
35
+
"accept": "text/x-patch",
36
+
"description": "patch content"
31
37
},
32
38
"source": {
33
39
"type": "ref",
+71
lexicons/repo/listRepos.json
+71
lexicons/repo/listRepos.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listRepos",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"description": "Lists all users (DIDs) and their repositories",
8
+
"parameters": {
9
+
"type": "params",
10
+
"properties": {}
11
+
},
12
+
"output": {
13
+
"encoding": "application/json",
14
+
"schema": {
15
+
"type": "object",
16
+
"required": ["users"],
17
+
"properties": {
18
+
"users": {
19
+
"type": "array",
20
+
"items": {
21
+
"type": "ref",
22
+
"ref": "#user"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
},
29
+
"user": {
30
+
"type": "object",
31
+
"required": ["did", "repos"],
32
+
"properties": {
33
+
"did": {
34
+
"type": "string",
35
+
"format": "did",
36
+
"description": "DID of the user"
37
+
},
38
+
"repos": {
39
+
"type": "array",
40
+
"items": {
41
+
"type": "ref",
42
+
"ref": "#repoEntry"
43
+
}
44
+
}
45
+
}
46
+
},
47
+
"repoEntry": {
48
+
"type": "object",
49
+
"required": ["name", "did", "fullPath"],
50
+
"properties": {
51
+
"name": {
52
+
"type": "string",
53
+
"description": "Repository name"
54
+
},
55
+
"did": {
56
+
"type": "string",
57
+
"format": "did",
58
+
"description": "DID of the repository owner"
59
+
},
60
+
"fullPath": {
61
+
"type": "string",
62
+
"description": "Full path to the repository"
63
+
},
64
+
"defaultBranch": {
65
+
"type": "string",
66
+
"description": "Default branch of the repository"
67
+
}
68
+
}
69
+
}
70
+
}
71
+
}
+21
-3
spindle/server.go
+21
-3
spindle/server.go
···
8
8
"log/slog"
9
9
"maps"
10
10
"net/http"
11
+
"sync"
11
12
12
13
"github.com/go-chi/chi/v5"
13
14
"tangled.org/core/api/tangled"
···
30
31
)
31
32
32
33
//go:embed motd
33
-
var motd []byte
34
+
var defaultMotd []byte
34
35
35
36
const (
36
37
rbacDomain = "thisserver"
···
47
48
cfg *config.Config
48
49
ks *eventconsumer.Consumer
49
50
res *idresolver.Resolver
50
-
vault secrets.Manager
51
+
vault secrets.Manager
52
+
motd []byte
53
+
motdMu sync.RWMutex
51
54
}
52
55
53
56
// New creates a new Spindle server with the provided configuration and engines.
···
128
131
cfg: cfg,
129
132
res: resolver,
130
133
vault: vault,
134
+
motd: defaultMotd,
131
135
}
132
136
133
137
err = e.AddSpindle(rbacDomain)
···
201
205
return s.e
202
206
}
203
207
208
+
// SetMotdContent sets custom MOTD content, replacing the embedded default.
209
+
func (s *Spindle) SetMotdContent(content []byte) {
210
+
s.motdMu.Lock()
211
+
defer s.motdMu.Unlock()
212
+
s.motd = content
213
+
}
214
+
215
+
// GetMotdContent returns the current MOTD content.
216
+
func (s *Spindle) GetMotdContent() []byte {
217
+
s.motdMu.RLock()
218
+
defer s.motdMu.RUnlock()
219
+
return s.motd
220
+
}
221
+
204
222
// Start starts the Spindle server (blocking).
205
223
func (s *Spindle) Start(ctx context.Context) error {
206
224
// starts a job queue runner in the background
···
246
264
mux := chi.NewRouter()
247
265
248
266
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
249
-
w.Write(motd)
267
+
w.Write(s.GetMotdContent())
250
268
})
251
269
mux.HandleFunc("/events", s.Events)
252
270
mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs)
+3
types/diff.go
+3
types/diff.go
+112
types/diff_test.go
+112
types/diff_test.go
···
1
+
package types
2
+
3
+
import "testing"
4
+
5
+
func TestDiffId(t *testing.T) {
6
+
tests := []struct {
7
+
name string
8
+
diff Diff
9
+
expected string
10
+
}{
11
+
{
12
+
name: "regular file uses new name",
13
+
diff: Diff{
14
+
Name: struct {
15
+
Old string `json:"old"`
16
+
New string `json:"new"`
17
+
}{Old: "", New: "src/main.go"},
18
+
},
19
+
expected: "src/main.go",
20
+
},
21
+
{
22
+
name: "new file uses new name",
23
+
diff: Diff{
24
+
Name: struct {
25
+
Old string `json:"old"`
26
+
New string `json:"new"`
27
+
}{Old: "", New: "src/new.go"},
28
+
IsNew: true,
29
+
},
30
+
expected: "src/new.go",
31
+
},
32
+
{
33
+
name: "deleted file uses old name",
34
+
diff: Diff{
35
+
Name: struct {
36
+
Old string `json:"old"`
37
+
New string `json:"new"`
38
+
}{Old: "src/deleted.go", New: ""},
39
+
IsDelete: true,
40
+
},
41
+
expected: "src/deleted.go",
42
+
},
43
+
{
44
+
name: "renamed file uses new name",
45
+
diff: Diff{
46
+
Name: struct {
47
+
Old string `json:"old"`
48
+
New string `json:"new"`
49
+
}{Old: "src/old.go", New: "src/renamed.go"},
50
+
IsRename: true,
51
+
},
52
+
expected: "src/renamed.go",
53
+
},
54
+
}
55
+
56
+
for _, tt := range tests {
57
+
t.Run(tt.name, func(t *testing.T) {
58
+
if got := tt.diff.Id(); got != tt.expected {
59
+
t.Errorf("Diff.Id() = %q, want %q", got, tt.expected)
60
+
}
61
+
})
62
+
}
63
+
}
64
+
65
+
func TestChangedFilesMatchesDiffId(t *testing.T) {
66
+
// ChangedFiles() must return values matching each Diff's Id()
67
+
// so that sidebar links point to the correct anchors.
68
+
// Tests existing, deleted, new, and renamed files.
69
+
nd := NiceDiff{
70
+
Diff: []Diff{
71
+
{
72
+
Name: struct {
73
+
Old string `json:"old"`
74
+
New string `json:"new"`
75
+
}{Old: "", New: "src/modified.go"},
76
+
},
77
+
{
78
+
Name: struct {
79
+
Old string `json:"old"`
80
+
New string `json:"new"`
81
+
}{Old: "src/deleted.go", New: ""},
82
+
IsDelete: true,
83
+
},
84
+
{
85
+
Name: struct {
86
+
Old string `json:"old"`
87
+
New string `json:"new"`
88
+
}{Old: "", New: "src/new.go"},
89
+
IsNew: true,
90
+
},
91
+
{
92
+
Name: struct {
93
+
Old string `json:"old"`
94
+
New string `json:"new"`
95
+
}{Old: "src/old.go", New: "src/renamed.go"},
96
+
IsRename: true,
97
+
},
98
+
},
99
+
}
100
+
101
+
changedFiles := nd.ChangedFiles()
102
+
103
+
if len(changedFiles) != len(nd.Diff) {
104
+
t.Fatalf("ChangedFiles() returned %d items, want %d", len(changedFiles), len(nd.Diff))
105
+
}
106
+
107
+
for i, diff := range nd.Diff {
108
+
if changedFiles[i] != diff.Id() {
109
+
t.Errorf("ChangedFiles()[%d] = %q, but Diff.Id() = %q", i, changedFiles[i], diff.Id())
110
+
}
111
+
}
112
+
}