Allow issues and comments to be updated in database #1

merged
opened by willdot.net targeting main from test-server

This allows issues and comments to be updated in the database should the content of the issue or comment be updated on Tangled.

It also adds a small HTTP server to fetch the issues/comments from the database for debugging.

+1
Dockerfile
··· 6 6 RUN go mod download 7 7 COPY . . 8 8 9 + #compiling for Pi at the moment 9 10 RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=5 go build -a -installsuffix cgo -o tangled-alert-bot ./cmd/. 10 11 11 12 FROM alpine:latest
+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 = "./" ··· 54 61 55 62 go consumeLoop(ctx, database) 56 63 64 + go startHttpServer(ctx, database) 65 + 57 66 <-signals 58 67 cancel() 59 68 ··· 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
··· 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
··· 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 2 tangled-alert-bot: 3 3 container_name: tangled-alert-bot 4 4 image: willdot/tangled-alert-bot 5 + ports: 6 + - "3000:3000" 5 7 volumes: 6 8 - ./data:/app/data 7 9 environment:
+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
··· 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=