+1
Dockerfile
+1
Dockerfile
+66
cmd/main.go
+66
cmd/main.go
···
2
2
3
3
import (
4
4
"context"
5
+
"encoding/json"
5
6
"errors"
6
7
"fmt"
7
8
"log"
8
9
"log/slog"
10
+
"net/http"
9
11
"os"
10
12
"os/signal"
11
13
"path"
···
14
16
tangledalertbot "tangled.sh/willdot.net/tangled-alert-bot"
15
17
16
18
"github.com/avast/retry-go/v4"
19
+
"github.com/bugsnag/bugsnag-go"
17
20
"github.com/joho/godotenv"
18
21
)
19
22
···
37
40
signals := make(chan os.Signal, 1)
38
41
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
39
42
43
+
bugsnag.Configure(bugsnag.Configuration{
44
+
APIKey: os.Getenv("BUGSNAG"),
45
+
})
46
+
40
47
dbPath := os.Getenv("DATABASE_PATH")
41
48
if dbPath == "" {
42
49
dbPath = "./"
···
53
60
defer cancel()
54
61
55
62
go consumeLoop(ctx, database)
63
+
64
+
go startHttpServer(ctx, database)
56
65
57
66
<-signals
58
67
cancel()
···
78
87
return nil
79
88
}
80
89
slog.Error("consume loop", "error", err)
90
+
bugsnag.Notify(err)
81
91
return err
82
92
}
83
93
return nil
···
85
95
86
96
slog.Warn("exiting consume loop")
87
97
}
98
+
99
+
func startHttpServer(ctx context.Context, db *tangledalertbot.Database) {
100
+
srv := server{
101
+
db: db,
102
+
}
103
+
mux := http.NewServeMux()
104
+
mux.HandleFunc("/issues", srv.handleListIssues)
105
+
mux.HandleFunc("/comments", srv.handleListComments)
106
+
107
+
err := http.ListenAndServe(":3000", mux)
108
+
if err != nil {
109
+
slog.Error("http listen and serve", "error", err)
110
+
}
111
+
}
112
+
113
+
type server struct {
114
+
db *tangledalertbot.Database
115
+
}
116
+
117
+
func (s *server) handleListIssues(w http.ResponseWriter, r *http.Request) {
118
+
issues, err := s.db.GetIssues()
119
+
if err != nil {
120
+
slog.Error("getting issues from DB", "error", err)
121
+
http.Error(w, "error getting issues from DB", http.StatusInternalServerError)
122
+
return
123
+
}
124
+
125
+
b, err := json.Marshal(issues)
126
+
if err != nil {
127
+
slog.Error("marshalling issues from DB", "error", err)
128
+
http.Error(w, "marshalling issues from DB", http.StatusInternalServerError)
129
+
return
130
+
}
131
+
132
+
w.Header().Set("Content-Type", "application/json")
133
+
w.Write(b)
134
+
}
135
+
136
+
func (s *server) handleListComments(w http.ResponseWriter, r *http.Request) {
137
+
comments, err := s.db.GetComments()
138
+
if err != nil {
139
+
slog.Error("getting comments from DB", "error", err)
140
+
http.Error(w, "error getting comments from DB", http.StatusInternalServerError)
141
+
return
142
+
}
143
+
144
+
b, err := json.Marshal(comments)
145
+
if err != nil {
146
+
slog.Error("marshalling comments from DB", "error", err)
147
+
http.Error(w, "marshalling comments from DB", http.StatusInternalServerError)
148
+
return
149
+
}
150
+
151
+
w.Header().Set("Content-Type", "application/json")
152
+
w.Write(b)
153
+
}
+10
-3
consumer.go
+10
-3
consumer.go
···
11
11
"github.com/bluesky-social/jetstream/pkg/client"
12
12
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
13
13
"github.com/bluesky-social/jetstream/pkg/models"
14
+
"github.com/bugsnag/bugsnag-go"
14
15
"tangled.sh/tangled.sh/core/api/tangled"
15
16
)
16
17
···
100
101
}
101
102
102
103
switch event.Commit.Operation {
103
-
case models.CommitOperationCreate:
104
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
104
105
return h.handleCreateEvent(ctx, event)
105
-
// TODO: handle deletes too
106
+
// TODO: handle deletes too
106
107
default:
107
108
return nil
108
109
}
···
127
128
128
129
err := json.Unmarshal(event.Commit.Record, &issue)
129
130
if err != nil {
131
+
bugsnag.Notify(err)
130
132
slog.Error("error unmarshalling event record to issue", "error", err)
131
133
return
132
134
}
···
136
138
137
139
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
138
140
if err != nil {
141
+
bugsnag.Notify(err)
139
142
slog.Error("parsing createdAt time from issue", "error", err, "timestamp", issue.CreatedAt)
140
143
createdAt = time.Now().UTC()
141
144
}
142
145
body := ""
143
146
if issue.Body != nil {
144
-
body = *&body
147
+
body = *issue.Body
145
148
}
146
149
err = h.store.CreateIssue(Issue{
147
150
AuthorDID: did,
···
152
155
Repo: issue.Repo,
153
156
})
154
157
if err != nil {
158
+
bugsnag.Notify(err)
155
159
slog.Error("create issue", "error", err, "did", did, "rkey", rkey)
156
160
return
157
161
}
···
163
167
164
168
err := json.Unmarshal(event.Commit.Record, &comment)
165
169
if err != nil {
170
+
bugsnag.Notify(err)
166
171
slog.Error("error unmarshalling event record to comment", "error", err)
167
172
return
168
173
}
···
172
177
173
178
createdAt, err := time.Parse(time.RFC3339, comment.CreatedAt)
174
179
if err != nil {
180
+
bugsnag.Notify(err)
175
181
slog.Error("parsing createdAt time from comment", "error", err, "timestamp", comment.CreatedAt)
176
182
createdAt = time.Now().UTC()
177
183
}
···
184
190
//ReplyTo: comment, // TODO: there should be a ReplyTo field that can be used as well once the right type is imported
185
191
})
186
192
if err != nil {
193
+
bugsnag.Notify(err)
187
194
slog.Error("create comment", "error", err, "did", did, "rkey", rkey)
188
195
return
189
196
}
+42
-2
database.go
+42
-2
database.go
···
125
125
126
126
// CreateIssue will insert a issue into a database
127
127
func (d *Database) CreateIssue(issue Issue) error {
128
-
sql := `INSERT INTO issues (authorDid, rkey, title, body, repo, createdAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(authorDid, rkey) DO NOTHING;`
128
+
sql := `REPLACE INTO issues (authorDid, rkey, title, body, repo, createdAt) VALUES (?, ?, ?, ?, ?, ?);`
129
129
_, err := d.db.Exec(sql, issue.AuthorDID, issue.RKey, issue.Title, issue.Body, issue.Repo, issue.CreatedAt)
130
130
if err != nil {
131
131
return fmt.Errorf("exec insert issue: %w", err)
···
135
135
136
136
// CreateComment will insert a comment into a database
137
137
func (d *Database) CreateComment(comment Comment) error {
138
-
sql := `INSERT INTO comments (authorDid, rkey, body, issue, replyTo, createdAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(authorDid, rkey) DO NOTHING;`
138
+
sql := `REPLACE INTO comments (authorDid, rkey, body, issue, replyTo, createdAt) VALUES (?, ?, ?, ?, ?, ?);`
139
139
_, err := d.db.Exec(sql, comment.AuthorDID, comment.RKey, comment.Body, comment.Issue, comment.ReplyTo, comment.CreatedAt)
140
140
if err != nil {
141
141
return fmt.Errorf("exec insert comment: %w", err)
142
142
}
143
143
return nil
144
144
}
145
+
146
+
func (d *Database) GetIssues() ([]Issue, error) {
147
+
sql := "SELECT authorDid, rkey, title, body, repo, createdAt FROM issues;"
148
+
rows, err := d.db.Query(sql)
149
+
if err != nil {
150
+
return nil, fmt.Errorf("run query to get issues: %w", err)
151
+
}
152
+
defer rows.Close()
153
+
154
+
var results []Issue
155
+
for rows.Next() {
156
+
var issue Issue
157
+
if err := rows.Scan(&issue.AuthorDID, &issue.RKey, &issue.Title, &issue.Body, &issue.Repo, &issue.CreatedAt); err != nil {
158
+
return nil, fmt.Errorf("scan row: %w", err)
159
+
}
160
+
161
+
results = append(results, issue)
162
+
}
163
+
return results, nil
164
+
}
165
+
166
+
func (d *Database) GetComments() ([]Comment, error) {
167
+
sql := "SELECT authorDid, rkey, body, issue, replyTo, createdAt FROM comments;"
168
+
rows, err := d.db.Query(sql)
169
+
if err != nil {
170
+
return nil, fmt.Errorf("run query to get comments: %w", err)
171
+
}
172
+
defer rows.Close()
173
+
174
+
var results []Comment
175
+
for rows.Next() {
176
+
var comment Comment
177
+
if err := rows.Scan(&comment.AuthorDID, &comment.RKey, &comment.Body, &comment.Issue, &comment.ReplyTo, &comment.CreatedAt); err != nil {
178
+
return nil, fmt.Errorf("scan row: %w", err)
179
+
}
180
+
181
+
results = append(results, comment)
182
+
}
183
+
return results, nil
184
+
}
+2
docker-compose.yaml
+2
docker-compose.yaml
+6
-1
go.mod
+6
-1
go.mod
···
5
5
require (
6
6
github.com/avast/retry-go/v4 v4.6.1
7
7
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336
8
+
github.com/bugsnag/bugsnag-go v2.6.2+incompatible
8
9
github.com/glebarez/go-sqlite v1.22.0
9
10
github.com/joho/godotenv v1.5.1
10
-
tangled.sh/tangled.sh/core v1.8.1-alpha
11
+
tangled.sh/tangled.sh/core v1.8.1-alpha.0.20250828210137-07b009bd6b98
11
12
)
12
13
13
14
require (
14
15
github.com/beorn7/perks v1.0.1 // indirect
16
+
github.com/bitly/go-simplejson v0.5.1 // indirect
15
17
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b // indirect
18
+
github.com/bugsnag/panicwrap v1.3.4 // indirect
16
19
github.com/cespare/xxhash/v2 v2.3.0 // indirect
17
20
github.com/dustin/go-humanize v1.0.1 // indirect
18
21
github.com/goccy/go-json v0.10.5 // indirect
19
22
github.com/google/uuid v1.6.0 // indirect
20
23
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
21
24
github.com/ipfs/go-cid v0.5.0 // indirect
25
+
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
22
26
github.com/klauspost/compress v1.18.0 // indirect
23
27
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
24
28
github.com/mattn/go-isatty v0.0.20 // indirect
···
30
34
github.com/multiformats/go-multihash v0.2.3 // indirect
31
35
github.com/multiformats/go-varint v0.0.7 // indirect
32
36
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
37
+
github.com/pkg/errors v0.9.1 // indirect
33
38
github.com/prometheus/client_golang v1.22.0 // indirect
34
39
github.com/prometheus/client_model v0.6.2 // indirect
35
40
github.com/prometheus/common v0.64.0 // indirect
+12
-2
go.sum
+12
-2
go.sum
···
2
2
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
3
3
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
4
4
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5
+
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
6
+
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
5
7
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b h1:bJTlFwMhB9sluuqZxVXtv2yFcaWOC/PZokz9mcwb4u4=
6
8
github.com/bluesky-social/indigo v0.0.0-20250808182429-6f0837c2d12b/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
7
9
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336 h1:NM3wfeFUrdjCE/xHLXQorwQvEKlI9uqnWl7L0Y9KA8U=
8
10
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336/go.mod h1:3ihWQCbXeayg41G8lQ5DfB/3NnEhl0XX24eZ3mLpf7Q=
11
+
github.com/bugsnag/bugsnag-go v2.6.2+incompatible h1:6R/uadVvhrciRbPXFmCY7sZ7ElbGKsxxOvG78HcGwj8=
12
+
github.com/bugsnag/bugsnag-go v2.6.2+incompatible/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
13
+
github.com/bugsnag/panicwrap v1.3.4 h1:A6sXFtDGsgU/4BLf5JT0o5uYg3EeKgGx3Sfs+/uk3pU=
14
+
github.com/bugsnag/panicwrap v1.3.4/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
9
15
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
10
16
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
11
17
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
···
28
34
github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
29
35
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
30
36
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
37
+
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
38
+
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
31
39
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
32
40
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
33
41
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
···
50
58
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
51
59
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
52
60
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
61
+
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
62
+
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
53
63
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
54
64
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
55
65
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
···
93
103
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
94
104
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
95
105
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
96
-
tangled.sh/tangled.sh/core v1.8.1-alpha h1:mCBXOUfzNCT1AnbMnaBrc/AgvfnxOIf5rSIescecpko=
97
-
tangled.sh/tangled.sh/core v1.8.1-alpha/go.mod h1:9kSVXCu9DMszZoQ5P4Rgdpz+RHLMjbHy++53qE7EBoU=
106
+
tangled.sh/tangled.sh/core v1.8.1-alpha.0.20250828210137-07b009bd6b98 h1:WovrwwBufU89zoSaStoc6+qyUTEB/LxhUCM1MqGEUwU=
107
+
tangled.sh/tangled.sh/core v1.8.1-alpha.0.20250828210137-07b009bd6b98/go.mod h1:zXmPB9VMsPWpJ6Y51PWnzB1fL3w69P0IhR9rTXIfGPY=