+2
appview/db/follow.go
+2
appview/db/follow.go
+1
appview/db/issues.go
+1
appview/db/issues.go
+1
-1
appview/db/language.go
+1
-1
appview/db/language.go
+6
-6
appview/db/pipeline.go
+6
-6
appview/db/pipeline.go
···
6
"strings"
7
"time"
8
9
-
"github.com/bluesky-social/indigo/atproto/syntax"
10
"tangled.org/core/appview/models"
11
"tangled.org/core/orm"
12
)
···
217
}
218
defer rows.Close()
219
220
-
pipelines := make(map[syntax.ATURI]models.Pipeline)
221
for rows.Next() {
222
var p models.Pipeline
223
var t models.Trigger
···
254
p.Trigger = &t
255
p.Statuses = make(map[string]models.WorkflowStatus)
256
257
-
pipelines[p.AtUri()] = p
258
}
259
260
// get all statuses
···
314
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
315
}
316
317
-
pipelineAt := ps.PipelineAt()
318
319
// extract
320
-
pipeline, ok := pipelines[pipelineAt]
321
if !ok {
322
continue
323
}
···
331
332
// reassign
333
pipeline.Statuses[ps.Workflow] = statuses
334
-
pipelines[pipelineAt] = pipeline
335
}
336
337
var all []models.Pipeline
···
6
"strings"
7
"time"
8
9
"tangled.org/core/appview/models"
10
"tangled.org/core/orm"
11
)
···
216
}
217
defer rows.Close()
218
219
+
pipelines := make(map[string]models.Pipeline)
220
for rows.Next() {
221
var p models.Pipeline
222
var t models.Trigger
···
253
p.Trigger = &t
254
p.Statuses = make(map[string]models.WorkflowStatus)
255
256
+
k := fmt.Sprintf("%s/%s", p.Knot, p.Rkey)
257
+
pipelines[k] = p
258
}
259
260
// get all statuses
···
314
return nil, fmt.Errorf("invalid status created timestamp %q: %w", created, err)
315
}
316
317
+
key := fmt.Sprintf("%s/%s", ps.PipelineKnot, ps.PipelineRkey)
318
319
// extract
320
+
pipeline, ok := pipelines[key]
321
if !ok {
322
continue
323
}
···
331
332
// reassign
333
pipeline.Statuses[ps.Workflow] = statuses
334
+
pipelines[key] = pipeline
335
}
336
337
var all []models.Pipeline
+5
appview/db/profile.go
+5
appview/db/profile.go
···
230
if err != nil {
231
return nil, err
232
}
233
234
profileMap := make(map[string]*models.Profile)
235
for rows.Next() {
···
270
if err != nil {
271
return nil, err
272
}
273
idxs := make(map[string]int)
274
for did := range profileMap {
275
idxs[did] = 0
···
290
if err != nil {
291
return nil, err
292
}
293
idxs = make(map[string]int)
294
for did := range profileMap {
295
idxs[did] = 0
···
230
if err != nil {
231
return nil, err
232
}
233
+
defer rows.Close()
234
235
profileMap := make(map[string]*models.Profile)
236
for rows.Next() {
···
271
if err != nil {
272
return nil, err
273
}
274
+
defer rows.Close()
275
+
276
idxs := make(map[string]int)
277
for did := range profileMap {
278
idxs[did] = 0
···
293
if err != nil {
294
return nil, err
295
}
296
+
defer rows.Close()
297
+
298
idxs = make(map[string]int)
299
for did := range profileMap {
300
idxs[did] = 0
+1
appview/db/registration.go
+1
appview/db/registration.go
+11
-1
appview/db/repos.go
+11
-1
appview/db/repos.go
···
56
limitClause,
57
)
58
rows, err := e.Query(repoQuery, args...)
59
-
60
if err != nil {
61
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
62
}
63
64
for rows.Next() {
65
var repo models.Repo
···
128
if err != nil {
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
130
}
131
for rows.Next() {
132
var repoat, labelat string
133
if err := rows.Scan(&repoat, &labelat); err != nil {
···
165
if err != nil {
166
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
167
}
168
for rows.Next() {
169
var repoat, lang string
170
if err := rows.Scan(&repoat, &lang); err != nil {
···
191
if err != nil {
192
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
193
}
194
for rows.Next() {
195
var repoat string
196
var count int
···
220
if err != nil {
221
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
222
}
223
for rows.Next() {
224
var repoat string
225
var open, closed int
···
261
if err != nil {
262
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
263
}
264
for rows.Next() {
265
var repoat string
266
var open, merged, closed, deleted int
···
56
limitClause,
57
)
58
rows, err := e.Query(repoQuery, args...)
59
if err != nil {
60
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
61
}
62
+
defer rows.Close()
63
64
for rows.Next() {
65
var repo models.Repo
···
128
if err != nil {
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
130
}
131
+
defer rows.Close()
132
+
133
for rows.Next() {
134
var repoat, labelat string
135
if err := rows.Scan(&repoat, &labelat); err != nil {
···
167
if err != nil {
168
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
169
}
170
+
defer rows.Close()
171
+
172
for rows.Next() {
173
var repoat, lang string
174
if err := rows.Scan(&repoat, &lang); err != nil {
···
195
if err != nil {
196
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
197
}
198
+
defer rows.Close()
199
+
200
for rows.Next() {
201
var repoat string
202
var count int
···
226
if err != nil {
227
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
228
}
229
+
defer rows.Close()
230
+
231
for rows.Next() {
232
var repoat string
233
var open, closed int
···
269
if err != nil {
270
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
271
}
272
+
defer rows.Close()
273
+
274
for rows.Next() {
275
var repoat string
276
var open, merged, closed, deleted int
+1
appview/db/star.go
+1
appview/db/star.go
-10
appview/models/pipeline.go
-10
appview/models/pipeline.go
···
1
package models
2
3
import (
4
-
"fmt"
5
"slices"
6
"time"
7
8
"github.com/bluesky-social/indigo/atproto/syntax"
9
"github.com/go-git/go-git/v5/plumbing"
10
-
"tangled.org/core/api/tangled"
11
spindle "tangled.org/core/spindle/models"
12
"tangled.org/core/workflow"
13
)
···
25
// populate when querying for reverse mappings
26
Trigger *Trigger
27
Statuses map[string]WorkflowStatus
28
-
}
29
-
30
-
func (p *Pipeline) AtUri() syntax.ATURI {
31
-
return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", p.Knot, tangled.PipelineNSID, p.Rkey))
32
}
33
34
type WorkflowStatus struct {
···
134
Error *string
135
ExitCode int
136
}
137
-
138
-
func (ps *PipelineStatus) PipelineAt() syntax.ATURI {
139
-
return syntax.ATURI(fmt.Sprintf("at://did:web:%s/%s/%s", ps.PipelineKnot, tangled.PipelineNSID, ps.PipelineRkey))
140
-
}
···
1
package models
2
3
import (
4
"slices"
5
"time"
6
7
"github.com/bluesky-social/indigo/atproto/syntax"
8
"github.com/go-git/go-git/v5/plumbing"
9
spindle "tangled.org/core/spindle/models"
10
"tangled.org/core/workflow"
11
)
···
23
// populate when querying for reverse mappings
24
Trigger *Trigger
25
Statuses map[string]WorkflowStatus
26
}
27
28
type WorkflowStatus struct {
···
128
Error *string
129
ExitCode int
130
}
-1
appview/notify/merged_notifier.go
-1
appview/notify/merged_notifier.go
+6
-1
appview/pages/funcmap.go
+6
-1
appview/pages/funcmap.go
···
25
"github.com/dustin/go-humanize"
26
"github.com/go-enry/go-enry/v2"
27
"github.com/yuin/goldmark"
28
"tangled.org/core/appview/filetree"
29
"tangled.org/core/appview/models"
30
"tangled.org/core/appview/pages/markup"
···
261
},
262
"description": func(text string) template.HTML {
263
p.rctx.RendererType = markup.RendererTypeDefault
264
-
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
265
sanitized := p.rctx.SanitizeDescription(htmlString)
266
return template.HTML(sanitized)
267
},
···
25
"github.com/dustin/go-humanize"
26
"github.com/go-enry/go-enry/v2"
27
"github.com/yuin/goldmark"
28
+
emoji "github.com/yuin/goldmark-emoji"
29
"tangled.org/core/appview/filetree"
30
"tangled.org/core/appview/models"
31
"tangled.org/core/appview/pages/markup"
···
262
},
263
"description": func(text string) template.HTML {
264
p.rctx.RendererType = markup.RendererTypeDefault
265
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
266
+
goldmark.WithExtensions(
267
+
emoji.Emoji,
268
+
),
269
+
))
270
sanitized := p.rctx.SanitizeDescription(htmlString)
271
return template.HTML(sanitized)
272
},
+2
appview/pages/markup/markdown.go
+2
appview/pages/markup/markdown.go
···
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
"github.com/yuin/goldmark"
16
highlighting "github.com/yuin/goldmark-highlighting/v2"
17
"github.com/yuin/goldmark/ast"
18
"github.com/yuin/goldmark/extension"
···
66
),
67
callout.CalloutExtention,
68
textension.AtExt,
69
),
70
goldmark.WithParserOptions(
71
parser.WithAutoHeadingID(),
···
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
"github.com/yuin/goldmark"
16
+
"github.com/yuin/goldmark-emoji"
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
"github.com/yuin/goldmark/ast"
19
"github.com/yuin/goldmark/extension"
···
67
),
68
callout.CalloutExtention,
69
textension.AtExt,
70
+
emoji.Emoji,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
+1
-1
appview/pages/pages.go
+1
-1
appview/pages/pages.go
+5
appview/pages/templates/fragments/starBtn-oob.html
+5
appview/pages/templates/fragments/starBtn-oob.html
+1
-3
appview/pages/templates/fragments/starBtn.html
+1
-3
appview/pages/templates/fragments/starBtn.html
···
1
{{ define "fragments/starBtn" }}
2
<button
3
id="starBtn"
4
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
···
10
{{ end }}
11
12
hx-trigger="click"
13
-
hx-target="this"
14
-
hx-swap="outerHTML"
15
-
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
16
hx-disabled-elt="#starBtn"
17
>
18
{{ if .IsStarred }}
···
1
{{ define "fragments/starBtn" }}
2
+
{{/* NOTE: this fragment is always replaced with hx-swap-oob */}}
3
<button
4
id="starBtn"
5
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
···
11
{{ end }}
12
13
hx-trigger="click"
14
hx-disabled-elt="#starBtn"
15
>
16
{{ if .IsStarred }}
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
···
14
<div class="flex gap-2 items-center">
15
{{ if .State.IsClosed }}
16
<span class="text-gray-500 dark:text-gray-400">
17
-
{{ i "ban" "w-4 h-4" }}
18
</span>
19
{{ else if eq .Kind.String "issues" }}
20
<span class="text-green-600 dark:text-green-500">
21
-
{{ i "circle-dot" "w-4 h-4" }}
22
</span>
23
{{ else if .State.IsOpen }}
24
<span class="text-green-600 dark:text-green-500">
25
-
{{ i "git-pull-request" "w-4 h-4" }}
26
</span>
27
{{ else if .State.IsMerged }}
28
<span class="text-purple-600 dark:text-purple-500">
29
-
{{ i "git-merge" "w-4 h-4" }}
30
</span>
31
{{ else }}
32
<span class="text-gray-600 dark:text-gray-300">
33
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
34
</span>
35
{{ end }}
36
-
<a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
</div>
38
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
<div>
···
14
<div class="flex gap-2 items-center">
15
{{ if .State.IsClosed }}
16
<span class="text-gray-500 dark:text-gray-400">
17
+
{{ i "ban" "size-3" }}
18
</span>
19
{{ else if eq .Kind.String "issues" }}
20
<span class="text-green-600 dark:text-green-500">
21
+
{{ i "circle-dot" "size-3" }}
22
</span>
23
{{ else if .State.IsOpen }}
24
<span class="text-green-600 dark:text-green-500">
25
+
{{ i "git-pull-request" "size-3" }}
26
</span>
27
{{ else if .State.IsMerged }}
28
<span class="text-purple-600 dark:text-purple-500">
29
+
{{ i "git-merge" "size-3" }}
30
</span>
31
{{ else }}
32
<span class="text-gray-600 dark:text-gray-300">
33
+
{{ i "git-pull-request-closed" "size-3" }}
34
</span>
35
{{ end }}
36
+
<a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
</div>
38
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
<div>
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
-
<div class="flex gap-2 text-base">
21
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
+
<div class="flex gap-2 items-stretch text-base">
21
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
+2
-2
appview/pages/templates/user/fragments/followCard.html
+2
-2
appview/pages/templates/user/fragments/followCard.html
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
-
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
-
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+8
appview/pulls/pulls.go
+8
appview/pulls/pulls.go
···
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1367
return
1368
}
1369
}
1370
1371
if err = tx.Commit(); err != nil {
1372
log.Println("failed to create pull request", err)
1373
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1374
return
1375
}
1376
1377
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
···
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1367
return
1368
}
1369
+
1370
}
1371
1372
if err = tx.Commit(); err != nil {
1373
log.Println("failed to create pull request", err)
1374
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1375
return
1376
+
}
1377
+
1378
+
// notify about each pull
1379
+
//
1380
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1381
+
for _, p := range stack {
1382
+
s.notifier.NewPull(r.Context(), p)
1383
}
1384
1385
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+9
-9
flake.lock
+9
-9
flake.lock
···
35
"systems": "systems"
36
},
37
"locked": {
38
-
"lastModified": 1694529238,
39
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
-
"lastModified": 1754078208,
60
-
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
-
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
-
"lastModified": 1765186076,
154
-
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
-
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
158
"type": "github"
159
},
160
"original": {
···
35
"systems": "systems"
36
},
37
"locked": {
38
+
"lastModified": 1731533236,
39
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
+
"lastModified": 1763982521,
60
+
"narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
+
"rev": "02e63a239d6eabd595db56852535992c898eba72",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
158
"type": "github"
159
},
160
"original": {
+2
-1
go.mod
+2
-1
go.mod
···
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
github.com/yuin/goldmark v1.7.13
48
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
49
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
50
golang.org/x/crypto v0.40.0
51
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
52
golang.org/x/image v0.31.0
53
golang.org/x/net v0.42.0
54
-
golang.org/x/sync v0.17.0
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
56
gopkg.in/yaml.v3 v3.0.1
57
)
···
203
go.uber.org/atomic v1.11.0 // indirect
204
go.uber.org/multierr v1.11.0 // indirect
205
go.uber.org/zap v1.27.0 // indirect
206
golang.org/x/sys v0.34.0 // indirect
207
golang.org/x/text v0.29.0 // indirect
208
golang.org/x/time v0.12.0 // indirect
···
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
github.com/yuin/goldmark v1.7.13
48
+
github.com/yuin/goldmark-emoji v1.0.6
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
54
golang.org/x/net v0.42.0
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
56
gopkg.in/yaml.v3 v3.0.1
57
)
···
203
go.uber.org/atomic v1.11.0 // indirect
204
go.uber.org/multierr v1.11.0 // indirect
205
go.uber.org/zap v1.27.0 // indirect
206
+
golang.org/x/sync v0.17.0 // indirect
207
golang.org/x/sys v0.34.0 // indirect
208
golang.org/x/text v0.29.0 // indirect
209
golang.org/x/time v0.12.0 // indirect
+2
go.sum
+2
go.sum
···
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
509
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
510
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
···
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
509
+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
510
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
511
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
512
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+3
nix/gomod2nix.toml
+3
nix/gomod2nix.toml
···
530
[mod."github.com/yuin/goldmark"]
531
version = "v1.7.13"
532
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
533
+
[mod."github.com/yuin/goldmark-emoji"]
534
+
version = "v1.0.6"
535
+
hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
536
[mod."github.com/yuin/goldmark-highlighting/v2"]
537
version = "v2.0.0-20230729083705-37449abec8cc"
538
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+18
-2
spindle/db/events.go
+18
-2
spindle/db/events.go
···
18
EventJson string `json:"event"`
19
}
20
21
-
func (d *DB) insertEvent(event Event, notifier *notifier.Notifier) error {
22
_, err := d.Exec(
23
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
24
event.Rkey,
···
70
return evts, nil
71
}
72
73
func (d *DB) createStatusEvent(
74
workflowId models.WorkflowId,
75
statusKind models.StatusKind,
···
100
EventJson: string(eventJson),
101
}
102
103
-
return d.insertEvent(event, n)
104
105
}
106
···
18
EventJson string `json:"event"`
19
}
20
21
+
func (d *DB) InsertEvent(event Event, notifier *notifier.Notifier) error {
22
_, err := d.Exec(
23
`insert into events (rkey, nsid, event, created) values (?, ?, ?, ?)`,
24
event.Rkey,
···
70
return evts, nil
71
}
72
73
+
func (d *DB) CreateStatusEvent(rkey string, s tangled.PipelineStatus, n *notifier.Notifier) error {
74
+
eventJson, err := json.Marshal(s)
75
+
if err != nil {
76
+
return err
77
+
}
78
+
79
+
event := Event{
80
+
Rkey: rkey,
81
+
Nsid: tangled.PipelineStatusNSID,
82
+
Created: time.Now().UnixNano(),
83
+
EventJson: string(eventJson),
84
+
}
85
+
86
+
return d.InsertEvent(event, n)
87
+
}
88
+
89
func (d *DB) createStatusEvent(
90
workflowId models.WorkflowId,
91
statusKind models.StatusKind,
···
116
EventJson: string(eventJson),
117
}
118
119
+
return d.InsertEvent(event, n)
120
121
}
122
+1
spindle/db/repos.go
+1
spindle/db/repos.go
+5
-1
spindle/engine/engine.go
+5
-1
spindle/engine/engine.go
···
70
}
71
defer eng.DestroyWorkflow(ctx, wid)
72
73
+
secretValues := make([]string, len(allSecrets))
74
+
for i, s := range allSecrets {
75
+
secretValues[i] = s.Value
76
+
}
77
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
78
if err != nil {
79
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
80
wfLogger = nil
+10
-24
spindle/engines/nixery/engine.go
+10
-24
spindle/engines/nixery/engine.go
···
179
return err
180
}
181
e.registerCleanup(wid, func(ctx context.Context) error {
182
-
err := e.docker.NetworkRemove(ctx, networkName(wid))
183
-
if err != nil {
184
-
return fmt.Errorf("removing network: %w", err)
185
-
}
186
-
return nil
187
})
188
189
addl := wf.Data.(addlFields)
···
233
return fmt.Errorf("creating container: %w", err)
234
}
235
e.registerCleanup(wid, func(ctx context.Context) error {
236
-
err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
237
if err != nil {
238
-
return fmt.Errorf("stopping container: %w", err)
239
}
240
241
-
err = e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
242
RemoveVolumes: true,
243
RemoveLinks: false,
244
Force: false,
245
})
246
-
if err != nil {
247
-
return fmt.Errorf("removing container: %w", err)
248
-
}
249
-
return nil
250
})
251
252
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
···
402
}
403
404
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
405
-
fns := e.drainCleanups(wid)
406
407
for _, fn := range fns {
408
if err := fn(ctx); err != nil {
···
418
419
key := wid.String()
420
e.cleanup[key] = append(e.cleanup[key], fn)
421
-
}
422
-
423
-
func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc {
424
-
e.cleanupMu.Lock()
425
-
key := wid.String()
426
-
427
-
fns := e.cleanup[key]
428
-
delete(e.cleanup, key)
429
-
e.cleanupMu.Unlock()
430
-
431
-
return fns
432
}
433
434
func networkName(wid models.WorkflowId) string {
···
179
return err
180
}
181
e.registerCleanup(wid, func(ctx context.Context) error {
182
+
return e.docker.NetworkRemove(ctx, networkName(wid))
183
})
184
185
addl := wf.Data.(addlFields)
···
229
return fmt.Errorf("creating container: %w", err)
230
}
231
e.registerCleanup(wid, func(ctx context.Context) error {
232
+
err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{})
233
if err != nil {
234
+
return err
235
}
236
237
+
return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{
238
RemoveVolumes: true,
239
RemoveLinks: false,
240
Force: false,
241
})
242
})
243
244
err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{})
···
394
}
395
396
func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error {
397
+
e.cleanupMu.Lock()
398
+
key := wid.String()
399
+
400
+
fns := e.cleanup[key]
401
+
delete(e.cleanup, key)
402
+
e.cleanupMu.Unlock()
403
404
for _, fn := range fns {
405
if err := fn(ctx); err != nil {
···
415
416
key := wid.String()
417
e.cleanup[key] = append(e.cleanup[key], fn)
418
}
419
420
func networkName(wid models.WorkflowId) string {
+6
-1
spindle/models/logger.go
+6
-1
spindle/models/logger.go
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
}
16
17
-
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
18
path := LogFilePath(baseDir, wid)
19
20
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
25
return &WorkflowLogger{
26
file: file,
27
encoder: json.NewEncoder(file),
28
}, nil
29
}
30
···
62
63
func (w *dataWriter) Write(p []byte) (int, error) {
64
line := strings.TrimRight(string(p), "\r\n")
65
entry := NewDataLogLine(w.idx, line, w.stream)
66
if err := w.logger.encoder.Encode(entry); err != nil {
67
return 0, err
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
+
mask *SecretMask
16
}
17
18
+
func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) {
19
path := LogFilePath(baseDir, wid)
20
21
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
26
return &WorkflowLogger{
27
file: file,
28
encoder: json.NewEncoder(file),
29
+
mask: NewSecretMask(secretValues),
30
}, nil
31
}
32
···
64
65
func (w *dataWriter) Write(p []byte) (int, error) {
66
line := strings.TrimRight(string(p), "\r\n")
67
+
if w.logger.mask != nil {
68
+
line = w.logger.mask.Mask(line)
69
+
}
70
entry := NewDataLogLine(w.idx, line, w.stream)
71
if err := w.logger.encoder.Encode(entry); err != nil {
72
return 0, err
+1
-1
spindle/models/pipeline_env.go
+1
-1
spindle/models/pipeline_env.go
+51
spindle/models/secret_mask.go
+51
spindle/models/secret_mask.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"strings"
6
+
)
7
+
8
+
// SecretMask replaces secret values in strings with "***".
9
+
type SecretMask struct {
10
+
replacer *strings.Replacer
11
+
}
12
+
13
+
// NewSecretMask creates a mask for the given secret values.
14
+
// Also registers base64-encoded variants of each secret.
15
+
func NewSecretMask(values []string) *SecretMask {
16
+
var pairs []string
17
+
18
+
for _, value := range values {
19
+
if value == "" {
20
+
continue
21
+
}
22
+
23
+
pairs = append(pairs, value, "***")
24
+
25
+
b64 := base64.StdEncoding.EncodeToString([]byte(value))
26
+
if b64 != value {
27
+
pairs = append(pairs, b64, "***")
28
+
}
29
+
30
+
b64NoPad := strings.TrimRight(b64, "=")
31
+
if b64NoPad != b64 && b64NoPad != value {
32
+
pairs = append(pairs, b64NoPad, "***")
33
+
}
34
+
}
35
+
36
+
if len(pairs) == 0 {
37
+
return nil
38
+
}
39
+
40
+
return &SecretMask{
41
+
replacer: strings.NewReplacer(pairs...),
42
+
}
43
+
}
44
+
45
+
// Mask replaces all registered secret values with "***".
46
+
func (m *SecretMask) Mask(input string) string {
47
+
if m == nil || m.replacer == nil {
48
+
return input
49
+
}
50
+
return m.replacer.Replace(input)
51
+
}
+135
spindle/models/secret_mask_test.go
+135
spindle/models/secret_mask_test.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"testing"
6
+
)
7
+
8
+
func TestSecretMask_BasicMasking(t *testing.T) {
9
+
mask := NewSecretMask([]string{"mysecret123"})
10
+
11
+
input := "The password is mysecret123 in this log"
12
+
expected := "The password is *** in this log"
13
+
14
+
result := mask.Mask(input)
15
+
if result != expected {
16
+
t.Errorf("expected %q, got %q", expected, result)
17
+
}
18
+
}
19
+
20
+
func TestSecretMask_Base64Encoded(t *testing.T) {
21
+
secret := "mysecret123"
22
+
mask := NewSecretMask([]string{secret})
23
+
24
+
b64 := base64.StdEncoding.EncodeToString([]byte(secret))
25
+
input := "Encoded: " + b64
26
+
expected := "Encoded: ***"
27
+
28
+
result := mask.Mask(input)
29
+
if result != expected {
30
+
t.Errorf("expected %q, got %q", expected, result)
31
+
}
32
+
}
33
+
34
+
func TestSecretMask_Base64NoPadding(t *testing.T) {
35
+
// "test" encodes to "dGVzdA==" with padding
36
+
secret := "test"
37
+
mask := NewSecretMask([]string{secret})
38
+
39
+
b64NoPad := "dGVzdA" // base64 without padding
40
+
input := "Token: " + b64NoPad
41
+
expected := "Token: ***"
42
+
43
+
result := mask.Mask(input)
44
+
if result != expected {
45
+
t.Errorf("expected %q, got %q", expected, result)
46
+
}
47
+
}
48
+
49
+
func TestSecretMask_MultipleSecrets(t *testing.T) {
50
+
mask := NewSecretMask([]string{"password1", "apikey123"})
51
+
52
+
input := "Using password1 and apikey123 for auth"
53
+
expected := "Using *** and *** for auth"
54
+
55
+
result := mask.Mask(input)
56
+
if result != expected {
57
+
t.Errorf("expected %q, got %q", expected, result)
58
+
}
59
+
}
60
+
61
+
func TestSecretMask_MultipleOccurrences(t *testing.T) {
62
+
mask := NewSecretMask([]string{"secret"})
63
+
64
+
input := "secret appears twice: secret"
65
+
expected := "*** appears twice: ***"
66
+
67
+
result := mask.Mask(input)
68
+
if result != expected {
69
+
t.Errorf("expected %q, got %q", expected, result)
70
+
}
71
+
}
72
+
73
+
func TestSecretMask_ShortValues(t *testing.T) {
74
+
mask := NewSecretMask([]string{"abc", "xy", ""})
75
+
76
+
if mask == nil {
77
+
t.Fatal("expected non-nil mask")
78
+
}
79
+
80
+
input := "abc xy test"
81
+
expected := "*** *** test"
82
+
result := mask.Mask(input)
83
+
if result != expected {
84
+
t.Errorf("expected %q, got %q", expected, result)
85
+
}
86
+
}
87
+
88
+
func TestSecretMask_NilMask(t *testing.T) {
89
+
var mask *SecretMask
90
+
91
+
input := "some input text"
92
+
result := mask.Mask(input)
93
+
if result != input {
94
+
t.Errorf("expected %q, got %q", input, result)
95
+
}
96
+
}
97
+
98
+
func TestSecretMask_EmptyInput(t *testing.T) {
99
+
mask := NewSecretMask([]string{"secret"})
100
+
101
+
result := mask.Mask("")
102
+
if result != "" {
103
+
t.Errorf("expected empty string, got %q", result)
104
+
}
105
+
}
106
+
107
+
func TestSecretMask_NoMatch(t *testing.T) {
108
+
mask := NewSecretMask([]string{"secretvalue"})
109
+
110
+
input := "nothing to mask here"
111
+
result := mask.Mask(input)
112
+
if result != input {
113
+
t.Errorf("expected %q, got %q", input, result)
114
+
}
115
+
}
116
+
117
+
func TestSecretMask_EmptySecretsList(t *testing.T) {
118
+
mask := NewSecretMask([]string{})
119
+
120
+
if mask != nil {
121
+
t.Error("expected nil mask for empty secrets list")
122
+
}
123
+
}
124
+
125
+
func TestSecretMask_EmptySecretsFiltered(t *testing.T) {
126
+
mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"})
127
+
128
+
input := "Using validpassword here"
129
+
expected := "Using *** here"
130
+
131
+
result := mask.Mask(input)
132
+
if result != expected {
133
+
t.Errorf("expected %q, got %q", expected, result)
134
+
}
135
+
}
+4
-4
spindle/server.go
+4
-4
spindle/server.go
···
302
tpl.TriggerMetadata.Repo.Repo,
303
)
304
if err != nil {
305
-
return fmt.Errorf("failed to get repo: %w", err)
306
}
307
308
pipelineId := models.PipelineId{
···
323
Name: w.Name,
324
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
325
if err != nil {
326
-
return fmt.Errorf("db.StatusFailed: %w", err)
327
}
328
329
continue
···
337
338
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
339
if err != nil {
340
-
return fmt.Errorf("init workflow: %w", err)
341
}
342
343
// inject TANGLED_* env vars after InitWorkflow
···
354
Name: w.Name,
355
}, s.n)
356
if err != nil {
357
-
return fmt.Errorf("db.StatusPending: %w", err)
358
}
359
}
360
}
···
302
tpl.TriggerMetadata.Repo.Repo,
303
)
304
if err != nil {
305
+
return err
306
}
307
308
pipelineId := models.PipelineId{
···
323
Name: w.Name,
324
}, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n)
325
if err != nil {
326
+
return err
327
}
328
329
continue
···
337
338
ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl)
339
if err != nil {
340
+
return err
341
}
342
343
// inject TANGLED_* env vars after InitWorkflow
···
354
Name: w.Name,
355
}, s.n)
356
if err != nil {
357
+
return err
358
}
359
}
360
}