smlflg commited on
Commit
95b671e
·
verified ·
1 Parent(s): 0bb2fc9

Initial public upload from Projekte/Experimente

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .codex/config.toml +31 -0
  2. superlog/.codex/config.toml +31 -0
  3. superlog/.dockerignore +18 -0
  4. superlog/.gitignore +16 -0
  5. superlog/.nvmrc +1 -0
  6. superlog/LICENSE.md +202 -0
  7. superlog/Procfile +4 -0
  8. superlog/Procfile.portless +4 -0
  9. superlog/README.md +112 -0
  10. superlog/SAMUEL_LOCAL_SETUP.md +50 -0
  11. superlog/SECURITY.md +67 -0
  12. superlog/apps/api/Dockerfile +23 -0
  13. superlog/apps/api/node_modules/.bin/nanoid +17 -0
  14. superlog/apps/api/node_modules/.bin/pino +17 -0
  15. superlog/apps/api/node_modules/.bin/tsc +17 -0
  16. superlog/apps/api/node_modules/.bin/tsserver +17 -0
  17. superlog/apps/api/node_modules/.bin/tsx +17 -0
  18. superlog/apps/api/package.json +51 -0
  19. superlog/apps/api/scripts/test-agent-tracking-e2e.ts +634 -0
  20. superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts +72 -0
  21. superlog/apps/api/src/alerts-service.ts +391 -0
  22. superlog/apps/api/src/alerts.ts +97 -0
  23. superlog/apps/api/src/auth.ts +217 -0
  24. superlog/apps/api/src/boot-migrations.test.ts +28 -0
  25. superlog/apps/api/src/boot-migrations.ts +15 -0
  26. superlog/apps/api/src/dashboards-service.ts +252 -0
  27. superlog/apps/api/src/dashboards.ts +136 -0
  28. superlog/apps/api/src/email.ts +99 -0
  29. superlog/apps/api/src/env.ts +8 -0
  30. superlog/apps/api/src/feedback.ts +299 -0
  31. superlog/apps/api/src/gateway.ts +612 -0
  32. superlog/apps/api/src/github-branches.test.ts +51 -0
  33. superlog/apps/api/src/github-branches.ts +44 -0
  34. superlog/apps/api/src/github.test.ts +297 -0
  35. superlog/apps/api/src/github.ts +2037 -0
  36. superlog/apps/api/src/http-observability.test.ts +55 -0
  37. superlog/apps/api/src/http-observability.ts +100 -0
  38. superlog/apps/api/src/impersonation.ts +68 -0
  39. superlog/apps/api/src/incidents/detail.test.ts +74 -0
  40. superlog/apps/api/src/incidents/detail.ts +115 -0
  41. superlog/apps/api/src/incidents/list.test.ts +91 -0
  42. superlog/apps/api/src/incidents/list.ts +59 -0
  43. superlog/apps/api/src/incidents/pr-retry.test.ts +64 -0
  44. superlog/apps/api/src/incidents/pr-retry.ts +46 -0
  45. superlog/apps/api/src/incidents/pr-view.test.ts +76 -0
  46. superlog/apps/api/src/incidents/pr-view.ts +67 -0
  47. superlog/apps/api/src/incidents/resolution-side-effects.test.ts +107 -0
  48. superlog/apps/api/src/incidents/resolution-side-effects.ts +198 -0
  49. superlog/apps/api/src/incidents/search.test.ts +111 -0
  50. superlog/apps/api/src/incidents/search.ts +159 -0
.codex/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coding MCPs: project-local registration (2026-09-07).
2
+
3
+ [mcp_servers.serena]
4
+ command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
5
+ args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/Experimente", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
6
+ cwd = "/home/smlflg/Projekte/Experimente"
7
+ enabled = true
8
+ startup_timeout_sec = 120
9
+ tool_timeout_sec = 180
10
+
11
+ [mcp_servers.serena.env]
12
+ "SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
13
+
14
+ [mcp_servers.gitnexus]
15
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
16
+ args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
17
+ cwd = "/home/smlflg/Projekte/Experimente"
18
+ enabled = true
19
+ startup_timeout_sec = 120
20
+ tool_timeout_sec = 180
21
+
22
+ [mcp_servers.gitnexus.env]
23
+ "GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
24
+
25
+ [mcp_servers.context7]
26
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
27
+ args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
28
+ cwd = "/home/smlflg/Projekte/Experimente"
29
+ enabled = true
30
+ startup_timeout_sec = 120
31
+ tool_timeout_sec = 180
superlog/.codex/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coding MCPs: project-local registration (2026-09-07).
2
+
3
+ [mcp_servers.serena]
4
+ command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
5
+ args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/Experimente/superlog", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
6
+ cwd = "/home/smlflg/Projekte/Experimente/superlog"
7
+ enabled = true
8
+ startup_timeout_sec = 120
9
+ tool_timeout_sec = 180
10
+
11
+ [mcp_servers.serena.env]
12
+ "SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
13
+
14
+ [mcp_servers.gitnexus]
15
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
16
+ args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
17
+ cwd = "/home/smlflg/Projekte/Experimente/superlog"
18
+ enabled = true
19
+ startup_timeout_sec = 120
20
+ tool_timeout_sec = 180
21
+
22
+ [mcp_servers.gitnexus.env]
23
+ "GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
24
+
25
+ [mcp_servers.context7]
26
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
27
+ args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
28
+ cwd = "/home/smlflg/Projekte/Experimente/superlog"
29
+ enabled = true
30
+ startup_timeout_sec = 120
31
+ tool_timeout_sec = 180
superlog/.dockerignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .context
3
+ .railway
4
+ .turbo
5
+ .overmind.sock
6
+ tmp
7
+
8
+ node_modules
9
+ **/node_modules
10
+
11
+ .env
12
+ .env.*
13
+ **/.env
14
+ **/.env.*
15
+
16
+ dist
17
+ build
18
+ coverage
superlog/.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .turbo
4
+ .next
5
+ .env
6
+ .env.local
7
+ .env.superlog
8
+ *.log
9
+ .DS_Store
10
+ *.tsbuildinfo
11
+ .claude/*
12
+ !.claude/skills/
13
+ .playwright-mcp/
14
+ .overmind.sock
15
+ tmp/
16
+ target/
superlog/.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ 20
superlog/LICENSE.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
superlog/Procfile ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ api: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/api dev 2>&1 | tee tmp/logs/api.log
2
+ web: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/web dev 2>&1 | tee tmp/logs/web.log
3
+ worker: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/worker dev 2>&1 | tee tmp/logs/worker.log
4
+ proxy: mkdir -p tmp/logs && unset PORT && pnpm --filter @superlog/proxy dev 2>&1 | tee tmp/logs/proxy.log
superlog/Procfile.portless ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ api: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh api pnpm --filter @superlog/api dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/api.log"
2
+ web: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh web pnpm --filter @superlog/web dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/web.log"
3
+ worker: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/with-stack-env.sh pnpm --filter @superlog/worker dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/worker.log"
4
+ proxy: mkdir -p "${SUPERLOG_LOG_DIR:-tmp/logs}" && ./scripts/portless-run.sh proxy pnpm --filter @superlog/proxy dev 2>&1 | tee "${SUPERLOG_LOG_DIR:-tmp/logs}/proxy.log"
superlog/README.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <a href="https://superlog.sh">
3
+ <img width="1200" height="675" alt="Twitter post - 2" src="https://github.com/user-attachments/assets/c6ac3418-8e2f-4f8b-b25c-d75b3a094036" />
4
+
5
+ </a>
6
+
7
+ <div align="center" style="margin:24px 0;">
8
+
9
+ <br />
10
+
11
+ [![Last Commit](https://img.shields.io/github/last-commit/superloglabs/superlog?labelColor=333333&color=666666)](https://github.com/superloglabs/superlog/commits/main)
12
+ [![Commit Activity](https://img.shields.io/github/commit-activity/m/superloglabs/superlog?labelColor=333333&color=666666)](https://github.com/superloglabs/superlog/graphs/commit-activity)
13
+ [![Apache 2.0 License](https://img.shields.io/badge/License-Apache_2.0-555555.svg?labelColor=333333&color=666666)](./LICENSE.md)
14
+ <br>
15
+ ![Discord](https://img.shields.io/discord/1511214206123380867?link=https%3A%2F%2Fdiscord.gg%2FbqJYtW3n)
16
+ <a href="https://www.ycombinator.com"><img src="https://img.shields.io/badge/Y%20Combinator-P26-orange" alt="Y Combinator P26"></a>
17
+ [![Follow @superlogYC on X](https://img.shields.io/twitter/follow/superlogyc?logo=X&color=%23f5f5f5)](https://twitter.com/intent/follow?screen_name=superlogYC)
18
+
19
+ </div>
20
+
21
+ <p align="center">
22
+ <a href="https://superlog.sh">Website</a>
23
+ ·
24
+ <a href="https://github.com/superloglabs/superlog">Code</a>
25
+ ·
26
+ <a href="https://github.com/superloglabs/skills">Skills</a>
27
+ ·
28
+ <a href="https://github.com/superloglabs/otel-helpers">Helpers</a>
29
+ ·
30
+ <a href="https://discord.gg/bqJYtW3n">Discord</a>
31
+ </p>
32
+
33
+ ## About
34
+
35
+ [Superlog](https://superlog.sh) is an open-source agentic telemetry system. It
36
+ ingests traces, logs, and metrics, groups noisy signals into incidents, and watches your infra while you sleep.
37
+
38
+ ## Installation
39
+
40
+ You can install Superlog in your project by using our [skills](https://superlog.sh) in your favourite coding agent:
41
+
42
+ ```
43
+ Run npx skills add superloglabs/skills --all and use the skills to install Superlog in this project
44
+ ```
45
+
46
+
47
+
48
+ ## What is Superlog?
49
+
50
+ Superlog is an open-core observability workspace for OpenTelemetry data. It
51
+ ingests traces, logs, and metrics, groups noisy signals into incidents, and gives
52
+ teams a local-first product surface for debugging production systems.
53
+
54
+ This repository contains the fully open-source, free community edition:
55
+
56
+ - Web app and API
57
+ - OTLP ingest proxy
58
+ - Worker processes for incident grouping and background jobs
59
+ - Postgres schema and ClickHouse-backed telemetry queries
60
+ - Agent runner interfaces for pluggable investigation runtimes
61
+ - A default `community` agent runner that records a local incident summary
62
+
63
+ We also provide a hosted Superlog Cloud edition with a free tier, a pay-to-go plan and monthly credit packs.
64
+
65
+ ## Quick Start
66
+
67
+ Prerequisites:
68
+
69
+ - Node.js 20+
70
+ - pnpm 9+
71
+ - Docker
72
+
73
+ Install dependencies:
74
+
75
+ ```bash
76
+ pnpm install
77
+ ```
78
+
79
+ Start the local stack:
80
+
81
+ ```bash
82
+ docker compose up -d
83
+ pnpm --filter @superlog/db db:migrate
84
+ pnpm dev
85
+ ```
86
+
87
+ The default local services are:
88
+
89
+ - Web: `http://localhost:5173`
90
+ - API: `http://localhost:4100`
91
+ - OTLP intake: `http://localhost:4101`
92
+
93
+ ## Development
94
+
95
+ Run typechecks:
96
+
97
+ ```bash
98
+ pnpm typecheck
99
+ ```
100
+
101
+ ## Repository Layout
102
+
103
+ - `apps/web` - Vite/React frontend
104
+ - `apps/api` - HTTP API
105
+ - `apps/proxy` - OTLP intake proxy
106
+ - `apps/worker` - background workers and agent orchestration
107
+ - `packages/db` - Drizzle schema and migrations
108
+ - `packages/fingerprint` - telemetry fingerprinting helpers
109
+
110
+ ## License
111
+
112
+ Superlog is licensed under the [Apache License 2.0](./LICENSE.md).
superlog/SAMUEL_LOCAL_SETUP.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Samuel Local Superlog Setup
2
+
3
+ Status: isolated local sandbox.
4
+
5
+ ## What is running
6
+
7
+ - Web UI: http://127.0.0.1:5173
8
+ - API: http://127.0.0.1:4100
9
+ - Proxy health: http://127.0.0.1:4000/health
10
+ - OTEL collector HTTP: http://127.0.0.1:4318
11
+ - Postgres container: `superlog-sandbox-postgres` on host port `5434`
12
+ - ClickHouse container: `superlog-sandbox-clickhouse` on host ports `8123`, `9000`
13
+ - Collector container: `superlog-sandbox-collector` on host ports `4317`, `4318`
14
+
15
+ ## Isolation guarantees
16
+
17
+ - Cloned into `/home/smlflg/Projekte/Experimente/superlog`.
18
+ - No existing project was modified or instrumented.
19
+ - No GitHub/Slack/Linear/Anthropic/OAuth credentials were added.
20
+ - Local `.env` files exist only in this sandbox and are mode `600`.
21
+ - Docker objects use the `superlog-sandbox-*` names.
22
+
23
+ ## Stop
24
+
25
+ ```bash
26
+ # Stop web/API/worker/proxy dev process from Hermes if still running:
27
+ # process session: proc_6d60aaec9f83
28
+
29
+ # Stop Docker backend:
30
+ docker stop superlog-sandbox-collector superlog-sandbox-clickhouse superlog-sandbox-postgres
31
+ ```
32
+
33
+ ## Start again
34
+
35
+ ```bash
36
+ cd /home/smlflg/Projekte/Experimente/superlog
37
+
38
+ docker start superlog-sandbox-postgres superlog-sandbox-clickhouse superlog-sandbox-collector
39
+ corepack pnpm dev
40
+ ```
41
+
42
+ ## Remove sandbox data later
43
+
44
+ Only if you explicitly want to delete local sandbox data:
45
+
46
+ ```bash
47
+ docker rm -f superlog-sandbox-collector superlog-sandbox-clickhouse superlog-sandbox-postgres
48
+ docker volume rm superlog_sandbox_pg_data superlog_sandbox_ch_data
49
+ rm -rf /home/smlflg/Projekte/Experimente/superlog
50
+ ```
superlog/SECURITY.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security Policy
2
+
3
+ We take the security of Superlog and its users seriously. Thank you for helping
4
+ keep Superlog and the people who use it safe.
5
+
6
+ ## Supported Versions
7
+
8
+ Superlog ships from `main`. Security fixes are applied to the latest release and
9
+ the current `main` branch only. Older commits and tags are not patched — please
10
+ upgrade to the latest version before reporting an issue.
11
+
12
+ | Version | Supported |
13
+ | ------------------ | ------------------ |
14
+ | Latest release / `main` | :white_check_mark: |
15
+ | Anything older | :x: |
16
+
17
+ ## Reporting a Vulnerability
18
+
19
+ **Please do not report security vulnerabilities through public GitHub issues,
20
+ pull requests, or our Discord.**
21
+
22
+ Instead, use either of these private channels:
23
+
24
+ - **GitHub Private Vulnerability Reporting** (preferred): open a report from the
25
+ [Security tab](https://github.com/superloglabs/superlog/security/advisories/new)
26
+ of this repository. This keeps the discussion private until a fix is released.
27
+ - **Email**: send details to **security@superlog.sh**.
28
+
29
+ Please include as much of the following as you can:
30
+
31
+ - The type of issue (e.g. injection, authn/authz bypass, SSRF, RCE, data exposure).
32
+ - The affected component (`apps/web`, `apps/api`, `apps/proxy`, `apps/worker`,
33
+ `packages/*`) and file paths or commit if known.
34
+ - Step-by-step instructions to reproduce, including any required configuration.
35
+ - Proof-of-concept or exploit code, if you have it.
36
+ - The impact of the issue and how an attacker might exploit it.
37
+
38
+ This information helps us triage your report more quickly.
39
+
40
+ ## What to Expect
41
+
42
+ - **Acknowledgement** within 3 business days of your report.
43
+ - An **initial assessment** and severity triage within 7 business days.
44
+ - We will keep you informed of progress as we work on a fix, and we will let you
45
+ know when the issue is resolved.
46
+ - We practice **coordinated disclosure**: we ask that you give us a reasonable
47
+ opportunity to release a fix before any public disclosure. We are happy to
48
+ credit you in the release notes and advisory unless you prefer to remain
49
+ anonymous.
50
+
51
+ ## Scope
52
+
53
+ This policy covers the open-source code in this repository. Issues in the hosted
54
+ **Superlog Cloud** service should also be reported through the channels above.
55
+
56
+ Out of scope (please do not report these):
57
+
58
+ - Reports from automated scanners without a demonstrated, exploitable impact.
59
+ - Denial-of-service achievable only through unrealistic traffic volumes.
60
+ - Vulnerabilities in third-party dependencies that are already publicly known —
61
+ open a regular issue or PR to bump the dependency instead.
62
+
63
+ ## Safe Harbor
64
+
65
+ We will not pursue legal action against researchers who act in good faith,
66
+ follow this policy, avoid privacy violations and service degradation, and give
67
+ us a reasonable time to remediate before disclosing.
superlog/apps/api/Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build context: repo root. Build with `docker build -f apps/api/Dockerfile .`
2
+ FROM node:20-slim
3
+
4
+ WORKDIR /app
5
+
6
+ RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
7
+
8
+ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
9
+ COPY apps/api/package.json apps/api/
10
+ COPY packages/db/package.json packages/db/
11
+ COPY packages/fingerprint/package.json packages/fingerprint/
12
+
13
+ RUN pnpm install --frozen-lockfile --filter @superlog/api...
14
+
15
+ COPY tsconfig.base.json ./
16
+ COPY apps/api apps/api
17
+ COPY packages/db packages/db
18
+ COPY packages/fingerprint packages/fingerprint
19
+
20
+ ENV PORT=4100
21
+ EXPOSE 4100
22
+
23
+ CMD ["pnpm", "--filter", "@superlog/api", "start"]
superlog/apps/api/node_modules/.bin/nanoid ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules/nanoid/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/nanoid@5.1.9/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.js" "$@"
15
+ else
16
+ exec node "$basedir/../nanoid/bin/nanoid.js" "$@"
17
+ fi
superlog/apps/api/node_modules/.bin/pino ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules/pino/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules/pino/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/pino@10.3.1/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../pino/bin.js" "$@"
15
+ else
16
+ exec node "$basedir/../pino/bin.js" "$@"
17
+ fi
superlog/apps/api/node_modules/.bin/tsc ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
15
+ else
16
+ exec node "$basedir/../typescript/bin/tsc" "$@"
17
+ fi
superlog/apps/api/node_modules/.bin/tsserver ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
15
+ else
16
+ exec node "$basedir/../typescript/bin/tsserver" "$@"
17
+ fi
superlog/apps/api/node_modules/.bin/tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/tsx@4.21.0/node_modules:/home/smlflg/Projekte/Experimente/superlog/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../tsx/dist/cli.mjs" "$@"
15
+ else
16
+ exec node "$basedir/../tsx/dist/cli.mjs" "$@"
17
+ fi
superlog/apps/api/package.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@superlog/api",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "tsx watch --import ./tracing.ts src/index.ts",
9
+ "start": "tsx --import ./tracing.ts src/index.ts",
10
+ "test": "tsx --test src/**/*.test.ts",
11
+ "test:mcp-clickhouse-ranges": "tsx scripts/test-mcp-clickhouse-ranges.ts",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@aws-sdk/client-s3": "^3.1053.0",
16
+ "@clickhouse/client": "^1.9.0",
17
+ "@hono/node-server": "^1.13.7",
18
+ "@hono/standard-validator": "^0.2.2",
19
+ "@jridgewell/trace-mapping": "^0.3.31",
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
21
+ "@opentelemetry/api": "^1.9.1",
22
+ "@opentelemetry/api-logs": "^0.215.0",
23
+ "@opentelemetry/auto-instrumentations-node": "^0.73.0",
24
+ "@opentelemetry/exporter-logs-otlp-http": "^0.215.0",
25
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.215.0",
26
+ "@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
27
+ "@opentelemetry/resources": "^2.7.0",
28
+ "@opentelemetry/sdk-logs": "^0.215.0",
29
+ "@opentelemetry/sdk-metrics": "^2.7.0",
30
+ "@opentelemetry/sdk-node": "^0.215.0",
31
+ "@opentelemetry/semantic-conventions": "^1.40.0",
32
+ "@scalar/hono-api-reference": "^0.10.19",
33
+ "@superlog/db": "workspace:*",
34
+ "@superlog/fingerprint": "workspace:*",
35
+ "autumn-js": "^1.2.27",
36
+ "better-auth": "^1.6.11",
37
+ "dotenv": "^17.4.2",
38
+ "drizzle-orm": "^0.45.2",
39
+ "hono": "^4.6.12",
40
+ "hono-openapi": "^1.3.0",
41
+ "nanoid": "^5.0.9",
42
+ "pino": "^10.3.1",
43
+ "resend": "^6.12.3",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.1",
48
+ "tsx": "^4.19.2",
49
+ "typescript": "^5.7.2"
50
+ }
51
+ }
superlog/apps/api/scripts/test-agent-tracking-e2e.ts ADDED
@@ -0,0 +1,634 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * End-to-end integration test for agent PR / Linear ticket tracking.
3
+ *
4
+ * What this exercises:
5
+ * 1. Migrations apply cleanly (creates the four new tables + linear webhook columns).
6
+ * 2. Worker-side row creation: agent_pull_requests + initial pr_opened event.
7
+ * 3. GitHub webhook handler updates parent state and appends agent_pr_events
8
+ * for: pull_request closed/merged, push, pull_request_review,
9
+ * pull_request_review_comment, issue_comment.
10
+ * A merged PR also resolves the incident, cascades linked issues, and
11
+ * writes an incident timeline event.
12
+ * 4. Worker-side Linear ticket creation: agent_linear_tickets + ticket_filed event.
13
+ * 5. Linear webhook handler updates parent state and appends agent_linear_ticket_events
14
+ * for: Issue update (state change), Comment create.
15
+ * 6. Timeline merge: incident_events + agent_pr_events + agent_linear_ticket_events
16
+ * come back chronologically with actor metadata.
17
+ *
18
+ * What this does NOT exercise:
19
+ * - Real GitHub PR creation (requires installed GitHub App + repo).
20
+ * - Real Linear ticket creation (requires agent run with OAuth + MCP).
21
+ * - Linear webhook auto-registration on OAuth (requires public API_BASE_URL).
22
+ *
23
+ * Run: pnpm exec tsx scripts/test-agent-tracking-e2e.ts
24
+ */
25
+
26
+ import { strict as assert } from "node:assert";
27
+ import crypto from "node:crypto";
28
+ import "dotenv/config";
29
+ import { Hono } from "hono";
30
+ import { db, runMigrations, schema } from "@superlog/db";
31
+ import { and, asc, desc, eq, inArray } from "drizzle-orm";
32
+ import { mountGithubPublic } from "../src/github.js";
33
+ import { mountLinearPublic } from "../src/linear.js";
34
+
35
+ const TEST_TAG = `e2e-${Date.now()}`;
36
+ const GH_INSTALLATION_ID = Math.floor(Date.now() / 1000) + 9_000_000; // unique-ish
37
+ const REPO_FULL_NAME = `acme/${TEST_TAG}`;
38
+ const PR_NUMBER = 42;
39
+ const LINEAR_WORKSPACE_ID = `ws-${TEST_TAG}`;
40
+ const LINEAR_TICKET_ID = `linear-issue-${TEST_TAG}`;
41
+ const LINEAR_WEBHOOK_ID = `wh-${TEST_TAG}`;
42
+ const LINEAR_WEBHOOK_SECRET = "linear-test-secret";
43
+
44
+ process.env.GITHUB_APP_WEBHOOK_SECRET ??= "github-test-secret";
45
+ const GH_WEBHOOK_SECRET = process.env.GITHUB_APP_WEBHOOK_SECRET;
46
+
47
+ function step(label: string) {
48
+ console.log(`\n— ${label}`);
49
+ }
50
+
51
+ function ok(label: string) {
52
+ console.log(` ✓ ${label}`);
53
+ }
54
+
55
+ async function seed(): Promise<{
56
+ orgId: string;
57
+ projectId: string;
58
+ incidentId: string;
59
+ issueId: string;
60
+ agentRunId: string;
61
+ ghInstallRowId: string;
62
+ linearInstallRowId: string;
63
+ agentPrRowId: string;
64
+ agentTicketRowId: string;
65
+ }> {
66
+ // Org / user / project
67
+ const [org] = await db
68
+ .insert(schema.orgs)
69
+ .values({ name: `e2e-${TEST_TAG}`, slug: `e2e-${TEST_TAG}` })
70
+ .returning();
71
+ if (!org) throw new Error("seed org failed");
72
+
73
+ const [project] = await db
74
+ .insert(schema.projects)
75
+ .values({ orgId: org.id, name: "test", slug: `test-${TEST_TAG}` })
76
+ .returning();
77
+ if (!project) throw new Error("seed project failed");
78
+
79
+ // Incident + agentRun
80
+ const [incident] = await db
81
+ .insert(schema.incidents)
82
+ .values({
83
+ projectId: project.id,
84
+ title: "Test incident",
85
+ service: "api",
86
+ firstSeen: new Date(),
87
+ lastSeen: new Date(),
88
+ })
89
+ .returning();
90
+ if (!incident) throw new Error("seed incident failed");
91
+
92
+ const [issue] = await db
93
+ .insert(schema.issues)
94
+ .values({
95
+ projectId: project.id,
96
+ fingerprint: `fp-${TEST_TAG}`,
97
+ kind: "span",
98
+ service: "api",
99
+ exceptionType: "Error",
100
+ title: "Test issue",
101
+ message: "boom",
102
+ firstSeen: new Date(),
103
+ lastSeen: new Date(),
104
+ })
105
+ .returning();
106
+ if (!issue) throw new Error("seed issue failed");
107
+
108
+ await db.insert(schema.incidentIssues).values({
109
+ incidentId: incident.id,
110
+ issueId: issue.id,
111
+ });
112
+
113
+ const [agentRun] = await db
114
+ .insert(schema.agentRuns)
115
+ .values({ incidentId: incident.id, runtime: "anthropic", state: "running" })
116
+ .returning();
117
+ if (!agentRun) throw new Error("seed agent run failed");
118
+
119
+ await db.insert(schema.incidentEvents).values({
120
+ agentRunId: agentRun.id,
121
+ kind: "agent.session_started",
122
+ summary: "Agent session started",
123
+ });
124
+
125
+ // GitHub installation
126
+ const [ghInstall] = await db
127
+ .insert(schema.githubInstallations)
128
+ .values({
129
+ orgId: org.id,
130
+ projectId: project.id,
131
+ installationId: GH_INSTALLATION_ID,
132
+ accountLogin: "acme-bot",
133
+ accountType: "Organization",
134
+ repos: [{ id: 1, fullName: REPO_FULL_NAME, private: false }],
135
+ })
136
+ .returning();
137
+ if (!ghInstall) throw new Error("seed gh install failed");
138
+
139
+ // Linear installation (with webhook id/secret pre-populated)
140
+ const [linearInstall] = await db
141
+ .insert(schema.linearInstallations)
142
+ .values({
143
+ projectId: project.id,
144
+ workspaceId: LINEAR_WORKSPACE_ID,
145
+ workspaceName: "Acme",
146
+ accessToken: "fake-token",
147
+ webhookId: LINEAR_WEBHOOK_ID,
148
+ webhookSecret: LINEAR_WEBHOOK_SECRET,
149
+ })
150
+ .returning();
151
+ if (!linearInstall) throw new Error("seed linear install failed");
152
+
153
+ // Worker-created rows: pretend the worker already opened a PR + filed a ticket.
154
+ const [agentPr] = await db
155
+ .insert(schema.agentPullRequests)
156
+ .values({
157
+ incidentId: incident.id,
158
+ agentRunId: agentRun.id,
159
+ installationId: ghInstall.id,
160
+ repoFullName: REPO_FULL_NAME,
161
+ prNumber: PR_NUMBER,
162
+ prNodeId: "PR_node_1",
163
+ url: `https://github.com/${REPO_FULL_NAME}/pull/${PR_NUMBER}`,
164
+ branchName: "superlog/fix-1",
165
+ baseBranch: "main",
166
+ headSha: "deadbeef",
167
+ title: "[superlog] Fix bug",
168
+ state: "open",
169
+ lastSyncedAt: new Date(),
170
+ })
171
+ .returning();
172
+ if (!agentPr) throw new Error("seed agent_pr failed");
173
+
174
+ await db.insert(schema.agentPrEvents).values({
175
+ agentPrId: agentPr.id,
176
+ kind: "pr_opened",
177
+ summary: `Opened PR #${PR_NUMBER}`,
178
+ actorLogin: "superlog-bot",
179
+ payload: { url: agentPr.url },
180
+ providerEventId: `pr_opened:${REPO_FULL_NAME}#${PR_NUMBER}`,
181
+ occurredAt: new Date(),
182
+ });
183
+
184
+ const [agentTicket] = await db
185
+ .insert(schema.agentLinearTickets)
186
+ .values({
187
+ incidentId: incident.id,
188
+ agentRunId: agentRun.id,
189
+ installationId: linearInstall.id,
190
+ workspaceId: LINEAR_WORKSPACE_ID,
191
+ ticketId: LINEAR_TICKET_ID,
192
+ url: `https://linear.app/acme/issue/ENG-1`,
193
+ lastSyncedAt: new Date(),
194
+ })
195
+ .returning();
196
+ if (!agentTicket) throw new Error("seed agent_ticket failed");
197
+
198
+ await db.insert(schema.agentLinearTicketEvents).values({
199
+ agentLinearTicketId: agentTicket.id,
200
+ kind: "ticket_filed",
201
+ summary: `Filed ${LINEAR_TICKET_ID}`,
202
+ occurredAt: new Date(),
203
+ providerEventId: `ticket_filed:${LINEAR_WORKSPACE_ID}:${LINEAR_TICKET_ID}`,
204
+ });
205
+
206
+ return {
207
+ orgId: org.id,
208
+ projectId: project.id,
209
+ incidentId: incident.id,
210
+ issueId: issue.id,
211
+ agentRunId: agentRun.id,
212
+ ghInstallRowId: ghInstall.id,
213
+ linearInstallRowId: linearInstall.id,
214
+ agentPrRowId: agentPr.id,
215
+ agentTicketRowId: agentTicket.id,
216
+ };
217
+ }
218
+
219
+ async function cleanup(orgId: string): Promise<void> {
220
+ // FKs cascade from org → projects → incidents → agentRuns → events.
221
+ // github_installations / linear_installations cascade from org too.
222
+ await db.delete(schema.orgs).where(eq(schema.orgs.id, orgId));
223
+ }
224
+
225
+ function ghSign(body: string): string {
226
+ return `sha256=${crypto.createHmac("sha256", GH_WEBHOOK_SECRET).update(body).digest("hex")}`;
227
+ }
228
+
229
+ function linearSign(body: string): string {
230
+ return crypto.createHmac("sha256", LINEAR_WEBHOOK_SECRET).update(body).digest("hex");
231
+ }
232
+
233
+ async function postGithub(
234
+ app: Hono,
235
+ event: string,
236
+ delivery: string,
237
+ payload: unknown,
238
+ ): Promise<Response> {
239
+ const body = JSON.stringify(payload);
240
+ return app.request("/github/webhook", {
241
+ method: "POST",
242
+ headers: {
243
+ "content-type": "application/json",
244
+ "x-github-event": event,
245
+ "x-github-delivery": delivery,
246
+ "x-hub-signature-256": ghSign(body),
247
+ },
248
+ body,
249
+ });
250
+ }
251
+
252
+ async function postLinear(app: Hono, delivery: string, payload: unknown): Promise<Response> {
253
+ const body = JSON.stringify(payload);
254
+ return app.request("/linear/webhook", {
255
+ method: "POST",
256
+ headers: {
257
+ "content-type": "application/json",
258
+ "linear-signature": linearSign(body),
259
+ "linear-delivery": delivery,
260
+ },
261
+ body,
262
+ });
263
+ }
264
+
265
+ async function main() {
266
+ step("Apply migrations");
267
+ await runMigrations();
268
+ ok("migrations applied");
269
+
270
+ step("Seed test fixtures");
271
+ const seeded = await seed();
272
+ ok(`org=${seeded.orgId} pr=${seeded.agentPrRowId} ticket=${seeded.agentTicketRowId}`);
273
+
274
+ try {
275
+ // Build a Hono app with just the webhook routes mounted.
276
+ // biome-ignore lint/suspicious/noExplicitAny: test harness
277
+ const app = new Hono<any>();
278
+ mountGithubPublic(app);
279
+ mountLinearPublic(app);
280
+
281
+ step("GitHub webhook: pull_request_review submitted");
282
+ {
283
+ const res = await postGithub(app, "pull_request_review", `del-${TEST_TAG}-1`, {
284
+ action: "submitted",
285
+ repository: { full_name: REPO_FULL_NAME },
286
+ pull_request: { number: PR_NUMBER },
287
+ review: {
288
+ id: 1001,
289
+ state: "approved",
290
+ user: { login: "alice", id: 100, avatar_url: "https://avatars.example/alice" },
291
+ },
292
+ sender: { login: "alice", id: 100 },
293
+ installation: { id: GH_INSTALLATION_ID },
294
+ });
295
+ assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`);
296
+
297
+ const events = await db.query.agentPrEvents.findMany({
298
+ where: eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
299
+ orderBy: [desc(schema.agentPrEvents.createdAt)],
300
+ limit: 1,
301
+ });
302
+ assert.equal(events[0]?.kind, "review_approved");
303
+ assert.equal(events[0]?.actorLogin, "alice");
304
+ ok(`event recorded: ${events[0]?.kind} by ${events[0]?.actorLogin}`);
305
+ }
306
+
307
+ step("GitHub webhook: issue_comment on PR");
308
+ {
309
+ const res = await postGithub(app, "issue_comment", `del-${TEST_TAG}-2`, {
310
+ action: "created",
311
+ repository: { full_name: REPO_FULL_NAME },
312
+ issue: { number: PR_NUMBER, pull_request: { url: "..." } },
313
+ comment: {
314
+ id: 2002,
315
+ body: "LGTM",
316
+ user: { login: "bob", id: 200, avatar_url: "https://avatars.example/bob" },
317
+ },
318
+ installation: { id: GH_INSTALLATION_ID },
319
+ });
320
+ assert.equal(res.status, 200);
321
+
322
+ const ev = await db.query.agentPrEvents.findFirst({
323
+ where: and(
324
+ eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
325
+ eq(schema.agentPrEvents.kind, "issue_comment"),
326
+ ),
327
+ });
328
+ assert.ok(ev, "expected issue_comment event");
329
+ assert.equal(ev.actorLogin, "bob");
330
+ ok(`event recorded: issue_comment by ${ev.actorLogin}`);
331
+ }
332
+
333
+ step("GitHub webhook: push to head branch updates head_sha");
334
+ {
335
+ const res = await postGithub(app, "push", `del-${TEST_TAG}-3`, {
336
+ ref: "refs/heads/superlog/fix-1",
337
+ before: "deadbeef",
338
+ after: "cafebabe",
339
+ repository: { full_name: REPO_FULL_NAME },
340
+ pusher: { name: "alice" },
341
+ sender: { login: "alice", id: 100 },
342
+ commits: [{ id: "cafebabe", message: "fix" }],
343
+ installation: { id: GH_INSTALLATION_ID },
344
+ });
345
+ assert.equal(res.status, 200);
346
+
347
+ const pr = await db.query.agentPullRequests.findFirst({
348
+ where: eq(schema.agentPullRequests.id, seeded.agentPrRowId),
349
+ });
350
+ assert.equal(pr?.headSha, "cafebabe");
351
+ ok(`head_sha updated: ${pr?.headSha}`);
352
+ }
353
+
354
+ step("GitHub webhook: pull_request closed (merged)");
355
+ {
356
+ const mergedAt = new Date().toISOString();
357
+ const res = await postGithub(app, "pull_request", `del-${TEST_TAG}-4`, {
358
+ action: "closed",
359
+ repository: { full_name: REPO_FULL_NAME },
360
+ pull_request: {
361
+ number: PR_NUMBER,
362
+ merged: true,
363
+ merged_at: mergedAt,
364
+ closed_at: mergedAt,
365
+ merged_by: { login: "alice", id: 100 },
366
+ user: { login: "superlog-bot", id: 999 },
367
+ head: { sha: "cafebabe", ref: "superlog/fix-1" },
368
+ },
369
+ sender: { login: "alice", id: 100 },
370
+ installation: { id: GH_INSTALLATION_ID },
371
+ });
372
+ assert.equal(res.status, 200);
373
+
374
+ const pr = await db.query.agentPullRequests.findFirst({
375
+ where: eq(schema.agentPullRequests.id, seeded.agentPrRowId),
376
+ });
377
+ assert.equal(pr?.state, "merged");
378
+ assert.equal(pr?.mergedByLogin, "alice");
379
+ assert.ok(pr?.mergedAt, "expected mergedAt");
380
+
381
+ const incident = await db.query.incidents.findFirst({
382
+ where: eq(schema.incidents.id, seeded.incidentId),
383
+ });
384
+ assert.equal(incident?.status, "resolved");
385
+
386
+ const issue = await db.query.issues.findFirst({
387
+ where: eq(schema.issues.id, seeded.issueId),
388
+ });
389
+ assert.equal(issue?.status, "resolved");
390
+ assert.ok(issue?.resolvedAt, "expected issue resolvedAt");
391
+
392
+ const ev = await db.query.incidentEvents.findFirst({
393
+ where: and(
394
+ eq(schema.incidentEvents.agentRunId, seeded.agentRunId),
395
+ eq(schema.incidentEvents.kind, "incident_resolved"),
396
+ ),
397
+ });
398
+ assert.ok(ev, "expected incident_resolved timeline event");
399
+ assert.equal(ev.summary, `Incident resolved because PR #${PR_NUMBER} was merged.`);
400
+ ok(
401
+ `PR merged: state=${pr?.state}, incident=${incident?.status}, issue=${issue?.status}`,
402
+ );
403
+ }
404
+
405
+ step("GitHub webhook: dedup on duplicate delivery");
406
+ {
407
+ const res = await postGithub(app, "issue_comment", `del-${TEST_TAG}-2`, {
408
+ action: "created",
409
+ repository: { full_name: REPO_FULL_NAME },
410
+ issue: { number: PR_NUMBER, pull_request: { url: "..." } },
411
+ comment: { id: 2002, body: "LGTM", user: { login: "bob", id: 200 } },
412
+ installation: { id: GH_INSTALLATION_ID },
413
+ });
414
+ assert.equal(res.status, 200);
415
+ const events = await db.query.agentPrEvents.findMany({
416
+ where: and(
417
+ eq(schema.agentPrEvents.agentPrId, seeded.agentPrRowId),
418
+ eq(schema.agentPrEvents.providerEventId, `del-${TEST_TAG}-2`),
419
+ ),
420
+ });
421
+ assert.equal(events.length, 1, `expected 1 dedup'd event, got ${events.length}`);
422
+ ok(`duplicate delivery deduped (${events.length} row)`);
423
+ }
424
+
425
+ step("Linear webhook: Issue update with state change");
426
+ {
427
+ const res = await postLinear(app, `lin-${TEST_TAG}-1`, {
428
+ action: "update",
429
+ type: "Issue",
430
+ webhookId: LINEAR_WEBHOOK_ID,
431
+ webhookTimestamp: Date.now(),
432
+ createdAt: new Date().toISOString(),
433
+ updatedFrom: { stateId: "old-state" },
434
+ data: {
435
+ id: LINEAR_TICKET_ID,
436
+ identifier: "ENG-1",
437
+ title: "Bug fix",
438
+ url: `https://linear.app/acme/issue/ENG-1`,
439
+ state: { id: "in-progress", name: "In Progress", type: "started" },
440
+ assignee: { id: "user-99", name: "Charlie" },
441
+ },
442
+ actor: { id: "user-99", name: "Charlie" },
443
+ });
444
+ assert.equal(res.status, 200, `expected 200, got ${res.status}: ${await res.text()}`);
445
+
446
+ const ticket = await db.query.agentLinearTickets.findFirst({
447
+ where: eq(schema.agentLinearTickets.id, seeded.agentTicketRowId),
448
+ });
449
+ assert.equal(ticket?.state, "In Progress");
450
+ assert.equal(ticket?.stateType, "started");
451
+ assert.equal(ticket?.assigneeName, "Charlie");
452
+ ok(`ticket state: ${ticket?.state} (${ticket?.stateType}), assignee=${ticket?.assigneeName}`);
453
+
454
+ const ev = await db.query.agentLinearTicketEvents.findFirst({
455
+ where: and(
456
+ eq(schema.agentLinearTicketEvents.agentLinearTicketId, seeded.agentTicketRowId),
457
+ eq(schema.agentLinearTicketEvents.kind, "ticket_state_changed"),
458
+ ),
459
+ });
460
+ assert.ok(ev, "expected ticket_state_changed event");
461
+ assert.equal(ev.actorName, "Charlie");
462
+ ok(`event recorded: ${ev.kind} by ${ev.actorName}`);
463
+ }
464
+
465
+ step("Linear webhook: Comment create");
466
+ {
467
+ const res = await postLinear(app, `lin-${TEST_TAG}-2`, {
468
+ action: "create",
469
+ type: "Comment",
470
+ webhookId: LINEAR_WEBHOOK_ID,
471
+ createdAt: new Date().toISOString(),
472
+ data: {
473
+ id: "cmt-1",
474
+ issueId: LINEAR_TICKET_ID,
475
+ body: "Looks good",
476
+ user: { id: "user-99", name: "Charlie" },
477
+ },
478
+ });
479
+ assert.equal(res.status, 200);
480
+
481
+ const ev = await db.query.agentLinearTicketEvents.findFirst({
482
+ where: and(
483
+ eq(schema.agentLinearTicketEvents.agentLinearTicketId, seeded.agentTicketRowId),
484
+ eq(schema.agentLinearTicketEvents.kind, "ticket_comment"),
485
+ ),
486
+ });
487
+ assert.ok(ev, "expected ticket_comment event");
488
+ assert.equal(ev.actorName, "Charlie");
489
+ ok(`event recorded: ${ev.kind} by ${ev.actorName}`);
490
+ }
491
+
492
+ step("Linear webhook: bad signature rejected");
493
+ {
494
+ const res = await app.request("/linear/webhook", {
495
+ method: "POST",
496
+ headers: {
497
+ "content-type": "application/json",
498
+ "linear-signature": "0".repeat(64),
499
+ },
500
+ body: JSON.stringify({
501
+ webhookId: LINEAR_WEBHOOK_ID,
502
+ type: "Issue",
503
+ action: "update",
504
+ data: { id: LINEAR_TICKET_ID },
505
+ }),
506
+ });
507
+ assert.equal(res.status, 401);
508
+ ok(`bad signature → 401`);
509
+ }
510
+
511
+ step("Timeline merge: agent run + agent_pr + agent_linear, sorted");
512
+ {
513
+ const events = await loadTimelineForTest(seeded.incidentId, seeded.agentRunId);
514
+ const sources = new Set(events.map((e) => e.source));
515
+ assert.ok(sources.has("agent_run"), "missing agent run events");
516
+ assert.ok(sources.has("agent_pr"), "missing agent_pr events");
517
+ assert.ok(sources.has("agent_linear"), "missing agent_linear events");
518
+
519
+ // Verify chronological order
520
+ for (let i = 1; i < events.length; i++) {
521
+ assert.ok(
522
+ events[i - 1]!.createdAt <= events[i]!.createdAt,
523
+ `events out of order at index ${i}`,
524
+ );
525
+ }
526
+ // Verify actor surfaced for at least one PR event
527
+ const prEventWithActor = events.find((e) => e.source === "agent_pr" && e.actor);
528
+ assert.ok(prEventWithActor, "expected at least one PR event with actor metadata");
529
+ const incidentResolvedEvent = events.find(
530
+ (e) => e.source === "agent_run" && e.kind === "incident_resolved",
531
+ );
532
+ assert.ok(incidentResolvedEvent, "missing incident_resolved timeline event");
533
+ assert.equal(
534
+ incidentResolvedEvent.summary,
535
+ `Incident resolved because PR #${PR_NUMBER} was merged.`,
536
+ );
537
+ ok(`timeline has ${events.length} events across ${sources.size} sources, chronological`);
538
+ ok(`sample actor: ${prEventWithActor.actor?.name} (${prEventWithActor.kind})`);
539
+ }
540
+
541
+ console.log("\nAll assertions passed.");
542
+ } finally {
543
+ step("Cleanup");
544
+ await cleanup(seeded.orgId);
545
+ ok("test rows removed");
546
+ }
547
+ }
548
+
549
+ // Inline copy of apps/api/src/index.ts loadIncidentTimeline so the test doesn't
550
+ // have to spin up the whole api. If this drifts, the unit-of-truth is the api file.
551
+ async function loadTimelineForTest(incidentId: string, agentRunId: string) {
552
+ const [agentRunEvents, prRows, linearTickets] = await Promise.all([
553
+ db.query.incidentEvents.findMany({
554
+ where: eq(schema.incidentEvents.agentRunId, agentRunId),
555
+ orderBy: [asc(schema.incidentEvents.createdAt)],
556
+ }),
557
+ db.query.agentPullRequests.findMany({
558
+ where: eq(schema.agentPullRequests.incidentId, incidentId),
559
+ }),
560
+ db.query.agentLinearTickets.findMany({
561
+ where: eq(schema.agentLinearTickets.incidentId, incidentId),
562
+ }),
563
+ ]);
564
+
565
+ const [prEventRows, linearEventRows] = await Promise.all([
566
+ prRows.length === 0
567
+ ? []
568
+ : db.query.agentPrEvents.findMany({
569
+ where: inArray(
570
+ schema.agentPrEvents.agentPrId,
571
+ prRows.map((r) => r.id),
572
+ ),
573
+ orderBy: [asc(schema.agentPrEvents.occurredAt)],
574
+ }),
575
+ linearTickets.length === 0
576
+ ? []
577
+ : db.query.agentLinearTicketEvents.findMany({
578
+ where: inArray(
579
+ schema.agentLinearTicketEvents.agentLinearTicketId,
580
+ linearTickets.map((r) => r.id),
581
+ ),
582
+ orderBy: [asc(schema.agentLinearTicketEvents.occurredAt)],
583
+ }),
584
+ ]);
585
+
586
+ type Item = {
587
+ id: string;
588
+ kind: string;
589
+ summary: string | null;
590
+ createdAt: string;
591
+ source: "agent_run" | "agent_pr" | "agent_linear";
592
+ actor: { name: string | null } | null;
593
+ };
594
+ const items: Item[] = [];
595
+ for (const ev of agentRunEvents) {
596
+ items.push({
597
+ id: ev.id,
598
+ kind: ev.kind,
599
+ summary: ev.summary,
600
+ createdAt: ev.createdAt.toISOString(),
601
+ source: "agent_run",
602
+ actor: null,
603
+ });
604
+ }
605
+ for (const ev of prEventRows) {
606
+ items.push({
607
+ id: ev.id,
608
+ kind: ev.kind,
609
+ summary: ev.summary,
610
+ createdAt: ev.occurredAt.toISOString(),
611
+ source: "agent_pr",
612
+ actor: ev.actorLogin ? { name: ev.actorLogin } : null,
613
+ });
614
+ }
615
+ for (const ev of linearEventRows) {
616
+ items.push({
617
+ id: ev.id,
618
+ kind: ev.kind,
619
+ summary: ev.summary,
620
+ createdAt: ev.occurredAt.toISOString(),
621
+ source: "agent_linear",
622
+ actor: ev.actorName ? { name: ev.actorName } : null,
623
+ });
624
+ }
625
+ items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
626
+ return items;
627
+ }
628
+
629
+ main()
630
+ .then(() => process.exit(0))
631
+ .catch((e) => {
632
+ console.error("\nE2E test failed:", e);
633
+ process.exit(1);
634
+ });
superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import type { ClickHouseClient } from "@clickhouse/client";
3
+ import { listServices, queryLogs } from "../src/mcp/clickhouse.js";
4
+
5
+ type QueryCall = {
6
+ query: string;
7
+ query_params?: Record<string, unknown>;
8
+ format?: string;
9
+ };
10
+
11
+ function fakeClickHouse(calls: QueryCall[]): ClickHouseClient {
12
+ return {
13
+ query: async (args: QueryCall) => {
14
+ calls.push(args);
15
+ return { json: async () => [] };
16
+ },
17
+ } as unknown as ClickHouseClient;
18
+ }
19
+
20
+ function normalizedSql(sql: string): string {
21
+ return sql.replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ async function testDefaultRelativeRangeIsExecutableSql() {
25
+ const calls: QueryCall[] = [];
26
+ await queryLogs(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
27
+ limit: 10,
28
+ });
29
+
30
+ const sql = normalizedSql(calls[0]?.query ?? "");
31
+ assert.match(sql, /Timestamp >= now\(\) - INTERVAL 1 HOUR/);
32
+ assert.match(sql, /Timestamp <= now\(\)/);
33
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
34
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
35
+ }
36
+
37
+ async function testExplicitRelativeRangeIsExecutableSql() {
38
+ const calls: QueryCall[] = [];
39
+ await listServices(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
40
+ since: "now() - INTERVAL 7 DAY",
41
+ until: "now()",
42
+ });
43
+
44
+ const sql = normalizedSql(calls[0]?.query ?? "");
45
+ assert.match(sql, /Timestamp >= now\(\) - INTERVAL 7 DAY/);
46
+ assert.match(sql, /Timestamp <= now\(\)/);
47
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
48
+ assert.doesNotMatch(sql, /parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
49
+ }
50
+
51
+ async function testIsoRangeStillUsesParameterizedParsing() {
52
+ const calls: QueryCall[] = [];
53
+ await queryLogs(fakeClickHouse(calls), "00000000-0000-0000-0000-000000000001", {
54
+ range: {
55
+ since: "2026-05-06T13:12:08.771Z",
56
+ until: "2026-05-06T14:12:08.771Z",
57
+ },
58
+ limit: 10,
59
+ });
60
+
61
+ const sql = normalizedSql(calls[0]?.query ?? "");
62
+ assert.match(sql, /Timestamp >= parseDateTime64BestEffortOrZero\(\{since:String\}\)/);
63
+ assert.match(sql, /Timestamp <= parseDateTime64BestEffortOrZero\(\{until:String\}\)/);
64
+ assert.equal(calls[0]?.query_params?.since, "2026-05-06T13:12:08.771Z");
65
+ assert.equal(calls[0]?.query_params?.until, "2026-05-06T14:12:08.771Z");
66
+ }
67
+
68
+ await testDefaultRelativeRangeIsExecutableSql();
69
+ await testExplicitRelativeRangeIsExecutableSql();
70
+ await testIsoRangeStillUsesParameterizedParsing();
71
+
72
+ console.log("mcp clickhouse range tests passed");
superlog/apps/api/src/alerts-service.ts ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import { db, schema } from "@superlog/db";
3
+ import { and, asc, desc, eq } from "drizzle-orm";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import { z } from "zod";
6
+ import { logger } from "./logger.js";
7
+ import { type ResourceAttrFilter, countSeries, metricSeries, pickStep } from "./mcp/clickhouse.js";
8
+
9
+ const log = logger.child({ scope: "alerts" });
10
+
11
+ export const alertSourceSchema = z.enum(["logs", "traces", "metric"]);
12
+ export const alertAggregationSchema = z.enum(["count", "sum", "avg"]);
13
+ export const alertComparatorSchema = z.enum(["gt", "lt"]);
14
+ export const alertGroupModeSchema = z.enum(["per_group", "single"]);
15
+
16
+ const resourceAttrSchema = z.object({
17
+ key: z.string().min(1).max(200),
18
+ value: z.string().max(500),
19
+ });
20
+
21
+ export const alertFilterSchema = z.object({
22
+ resourceAttrs: z.array(resourceAttrSchema).max(50).optional(),
23
+ service: z.string().max(200).optional(),
24
+ severity: z.string().max(50).optional(),
25
+ spanName: z.string().max(200).optional(),
26
+ statusCode: z.string().max(50).optional(),
27
+ minDurationMs: z.number().nonnegative().optional(),
28
+ });
29
+
30
+ export const alertInputSchema = z.object({
31
+ name: z.string().min(1).max(200),
32
+ enabled: z.boolean().optional(),
33
+ source: alertSourceSchema,
34
+ metricName: z.string().max(200).nullable().optional(),
35
+ filter: alertFilterSchema.optional(),
36
+ groupBy: z.string().max(200).nullable().optional(),
37
+ groupMode: alertGroupModeSchema.optional(),
38
+ aggregation: alertAggregationSchema,
39
+ comparator: alertComparatorSchema,
40
+ threshold: z.number().finite(),
41
+ windowMinutes: z.number().int().min(1).max(1440).optional(),
42
+ evaluationIntervalSeconds: z.number().int().min(15).max(3600).optional(),
43
+ });
44
+
45
+ export type AlertInput = z.infer<typeof alertInputSchema>;
46
+
47
+ export function validateAlertInput(input: AlertInput): void {
48
+ if (input.source === "metric" && !input.metricName) {
49
+ throw new HTTPException(400, { message: "metricName required when source = metric" });
50
+ }
51
+ if (input.source !== "metric" && input.aggregation !== "count") {
52
+ throw new HTTPException(400, {
53
+ message: "aggregation must be 'count' for logs/traces sources",
54
+ });
55
+ }
56
+ if (input.source === "metric" && input.aggregation === "count") {
57
+ throw new HTTPException(400, {
58
+ message: "aggregation must be 'sum' or 'avg' for metric source",
59
+ });
60
+ }
61
+ if (input.groupMode === "per_group" && !input.groupBy) {
62
+ throw new HTTPException(400, { message: "groupBy required when groupMode = per_group" });
63
+ }
64
+ }
65
+
66
+ export function alertInputToFilter(input: AlertInput): schema.AlertFilter {
67
+ return {
68
+ resourceAttrs: input.filter?.resourceAttrs,
69
+ service: input.filter?.service,
70
+ severity: input.filter?.severity,
71
+ spanName: input.filter?.spanName,
72
+ statusCode: input.filter?.statusCode,
73
+ minDurationMs: input.filter?.minDurationMs,
74
+ };
75
+ }
76
+
77
+ function rangeForWindow(windowMinutes: number): { since: string; until: string } {
78
+ const now = new Date();
79
+ const since = new Date(now.getTime() - windowMinutes * 60_000);
80
+ return { since: since.toISOString(), until: now.toISOString() };
81
+ }
82
+
83
+ function compare(value: number, comparator: schema.AlertComparator, threshold: number): boolean {
84
+ if (comparator === "gt") return value > threshold;
85
+ return value < threshold;
86
+ }
87
+
88
+ export async function evaluateAlertQuery(
89
+ ch: ClickHouseClient,
90
+ alert: {
91
+ projectId: string;
92
+ source: schema.AlertSource;
93
+ metricName: string | null;
94
+ filter: schema.AlertFilter;
95
+ groupBy: string | null;
96
+ groupMode: schema.AlertGroupMode;
97
+ aggregation: schema.AlertAggregation;
98
+ windowMinutes: number;
99
+ },
100
+ ): Promise<{ groups: Map<string, number>; total: number }> {
101
+ const range = rangeForWindow(alert.windowMinutes);
102
+ const rangeSeconds = alert.windowMinutes * 60;
103
+ const step = pickStep(rangeSeconds);
104
+ const resourceAttrs: ResourceAttrFilter[] | undefined = alert.filter.resourceAttrs;
105
+ const groupBy = alert.groupBy || undefined;
106
+
107
+ const groupSums = new Map<string, number>();
108
+ const groupCounts = new Map<string, number>();
109
+
110
+ if (alert.source === "metric") {
111
+ if (!alert.metricName) return { groups: new Map(), total: 0 };
112
+ const rows = await metricSeries(
113
+ ch,
114
+ alert.projectId,
115
+ alert.metricName,
116
+ {
117
+ range,
118
+ service: alert.filter.service,
119
+ resourceAttrs,
120
+ },
121
+ groupBy,
122
+ step,
123
+ );
124
+ for (const row of rows) {
125
+ const key = row.group ?? "";
126
+ groupSums.set(key, (groupSums.get(key) ?? 0) + row.value);
127
+ groupCounts.set(key, (groupCounts.get(key) ?? 0) + 1);
128
+ }
129
+ } else {
130
+ const rows = await countSeries(
131
+ ch,
132
+ alert.projectId,
133
+ alert.source,
134
+ {
135
+ range,
136
+ service: alert.filter.service,
137
+ resourceAttrs,
138
+ severity: alert.filter.severity,
139
+ spanName: alert.filter.spanName,
140
+ statusCode: alert.filter.statusCode,
141
+ minDurationMs: alert.filter.minDurationMs,
142
+ },
143
+ groupBy,
144
+ step,
145
+ );
146
+ for (const row of rows) {
147
+ const key = row.group ?? "";
148
+ groupSums.set(key, (groupSums.get(key) ?? 0) + row.count);
149
+ groupCounts.set(key, (groupCounts.get(key) ?? 0) + 1);
150
+ }
151
+ }
152
+
153
+ const groups = new Map<string, number>();
154
+ for (const [key, sum] of groupSums) {
155
+ if (alert.aggregation === "avg") {
156
+ const n = groupCounts.get(key) ?? 1;
157
+ groups.set(key, n > 0 ? sum / n : 0);
158
+ } else {
159
+ groups.set(key, sum);
160
+ }
161
+ }
162
+
163
+ let total = 0;
164
+ if (alert.aggregation === "avg") {
165
+ let sumAll = 0;
166
+ let nAll = 0;
167
+ for (const [key, sum] of groupSums) {
168
+ sumAll += sum;
169
+ nAll += groupCounts.get(key) ?? 0;
170
+ }
171
+ total = nAll > 0 ? sumAll / nAll : 0;
172
+ } else {
173
+ for (const value of groupSums.values()) total += value;
174
+ }
175
+
176
+ return { groups, total };
177
+ }
178
+
179
+ export type AlertEvalResult =
180
+ | { mode: "single"; value: number; breaches: number }
181
+ | {
182
+ mode: "per_group";
183
+ groups: { key: string; value: number; breaching: boolean }[];
184
+ breaches: number;
185
+ };
186
+
187
+ export function summarizeEvaluation(
188
+ alert: {
189
+ comparator: schema.AlertComparator;
190
+ threshold: number;
191
+ groupMode: schema.AlertGroupMode;
192
+ groupBy: string | null;
193
+ },
194
+ evaluation: { groups: Map<string, number>; total: number },
195
+ ): AlertEvalResult {
196
+ if (alert.groupMode === "per_group" && alert.groupBy) {
197
+ const rows: { key: string; value: number; breaching: boolean }[] = [];
198
+ for (const [key, value] of evaluation.groups) {
199
+ rows.push({
200
+ key,
201
+ value,
202
+ breaching: compare(value, alert.comparator, alert.threshold),
203
+ });
204
+ }
205
+ rows.sort((a, b) => b.value - a.value);
206
+ return {
207
+ mode: "per_group",
208
+ groups: rows,
209
+ breaches: rows.filter((g) => g.breaching).length,
210
+ };
211
+ }
212
+ return {
213
+ mode: "single",
214
+ value: evaluation.total,
215
+ breaches: compare(evaluation.total, alert.comparator, alert.threshold) ? 1 : 0,
216
+ };
217
+ }
218
+
219
+ export async function listAlertsForProject(projectId: string): Promise<schema.Alert[]> {
220
+ return db.query.alerts.findMany({
221
+ where: eq(schema.alerts.projectId, projectId),
222
+ orderBy: [asc(schema.alerts.name)],
223
+ });
224
+ }
225
+
226
+ export async function getAlertWithFirings(
227
+ projectId: string,
228
+ id: string,
229
+ ): Promise<(schema.Alert & { firings: schema.AlertFiring[] }) | null> {
230
+ const alert = await db.query.alerts.findFirst({
231
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
232
+ });
233
+ if (!alert) return null;
234
+ const firings = await db.query.alertFirings.findMany({
235
+ where: eq(schema.alertFirings.alertId, id),
236
+ orderBy: [desc(schema.alertFirings.evaluatedAt)],
237
+ limit: 50,
238
+ });
239
+ return { ...alert, firings };
240
+ }
241
+
242
+ export async function createAlertRecord(
243
+ projectId: string,
244
+ userId: string,
245
+ input: AlertInput,
246
+ ): Promise<schema.Alert> {
247
+ validateAlertInput(input);
248
+ const inserted = await db
249
+ .insert(schema.alerts)
250
+ .values({
251
+ projectId,
252
+ name: input.name,
253
+ enabled: input.enabled ?? true,
254
+ source: input.source,
255
+ metricName: input.metricName ?? null,
256
+ filter: alertInputToFilter(input),
257
+ groupBy: input.groupBy ?? null,
258
+ groupMode: input.groupMode ?? "single",
259
+ aggregation: input.aggregation,
260
+ comparator: input.comparator,
261
+ threshold: input.threshold,
262
+ windowMinutes: input.windowMinutes ?? 5,
263
+ evaluationIntervalSeconds: input.evaluationIntervalSeconds ?? 60,
264
+ createdBy: userId,
265
+ })
266
+ .returning();
267
+ log.info(
268
+ {
269
+ alert_id: inserted[0]?.id,
270
+ project_id: projectId,
271
+ actor_user_id: userId,
272
+ source: input.source,
273
+ comparator: input.comparator,
274
+ threshold: input.threshold,
275
+ window_minutes: input.windowMinutes ?? 5,
276
+ },
277
+ "alert created",
278
+ );
279
+ const row = inserted[0];
280
+ if (!row) throw new Error("alerts insert returned no rows");
281
+ return row;
282
+ }
283
+
284
+ export async function updateAlertRecord(
285
+ projectId: string,
286
+ id: string,
287
+ patch: Partial<AlertInput>,
288
+ ): Promise<schema.Alert | null> {
289
+ const existing = await db.query.alerts.findFirst({
290
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
291
+ });
292
+ if (!existing) return null;
293
+
294
+ const merged: AlertInput = {
295
+ name: patch.name ?? existing.name,
296
+ enabled: patch.enabled ?? existing.enabled,
297
+ source: patch.source ?? existing.source,
298
+ metricName: patch.metricName !== undefined ? patch.metricName : existing.metricName,
299
+ filter: patch.filter ?? existing.filter,
300
+ groupBy: patch.groupBy !== undefined ? patch.groupBy : existing.groupBy,
301
+ groupMode: patch.groupMode ?? existing.groupMode,
302
+ aggregation: patch.aggregation ?? existing.aggregation,
303
+ comparator: patch.comparator ?? existing.comparator,
304
+ threshold: patch.threshold ?? existing.threshold,
305
+ windowMinutes: patch.windowMinutes ?? existing.windowMinutes,
306
+ evaluationIntervalSeconds:
307
+ patch.evaluationIntervalSeconds ?? existing.evaluationIntervalSeconds,
308
+ };
309
+ validateAlertInput(merged);
310
+
311
+ const updated = await db
312
+ .update(schema.alerts)
313
+ .set({
314
+ name: merged.name,
315
+ enabled: merged.enabled ?? existing.enabled,
316
+ source: merged.source,
317
+ metricName: merged.metricName ?? null,
318
+ filter: alertInputToFilter(merged),
319
+ groupBy: merged.groupBy ?? null,
320
+ groupMode: merged.groupMode ?? "single",
321
+ aggregation: merged.aggregation,
322
+ comparator: merged.comparator,
323
+ threshold: merged.threshold,
324
+ windowMinutes: merged.windowMinutes ?? 5,
325
+ evaluationIntervalSeconds: merged.evaluationIntervalSeconds ?? 60,
326
+ updatedAt: new Date(),
327
+ })
328
+ .where(and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)))
329
+ .returning();
330
+ return updated[0] ?? null;
331
+ }
332
+
333
+ export async function deleteAlertRecord(
334
+ projectId: string,
335
+ id: string,
336
+ actorUserId: string,
337
+ ): Promise<void> {
338
+ await db
339
+ .delete(schema.alerts)
340
+ .where(and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)));
341
+ log.info({ alert_id: id, project_id: projectId, actor_user_id: actorUserId }, "alert deleted");
342
+ }
343
+
344
+ export async function previewAlert(
345
+ ch: ClickHouseClient,
346
+ projectId: string,
347
+ input: AlertInput,
348
+ ): Promise<AlertEvalResult> {
349
+ validateAlertInput(input);
350
+ const evaluation = await evaluateAlertQuery(ch, {
351
+ projectId,
352
+ source: input.source,
353
+ metricName: input.metricName ?? null,
354
+ filter: alertInputToFilter(input),
355
+ groupBy: input.groupBy ?? null,
356
+ groupMode: input.groupMode ?? "single",
357
+ aggregation: input.aggregation,
358
+ windowMinutes: input.windowMinutes ?? 5,
359
+ });
360
+ return summarizeEvaluation(
361
+ {
362
+ comparator: input.comparator,
363
+ threshold: input.threshold,
364
+ groupMode: input.groupMode ?? "single",
365
+ groupBy: input.groupBy ?? null,
366
+ },
367
+ evaluation,
368
+ );
369
+ }
370
+
371
+ export async function testAlertById(
372
+ ch: ClickHouseClient,
373
+ projectId: string,
374
+ id: string,
375
+ ): Promise<AlertEvalResult | null> {
376
+ const alert = await db.query.alerts.findFirst({
377
+ where: and(eq(schema.alerts.id, id), eq(schema.alerts.projectId, projectId)),
378
+ });
379
+ if (!alert) return null;
380
+ const evaluation = await evaluateAlertQuery(ch, {
381
+ projectId: alert.projectId,
382
+ source: alert.source,
383
+ metricName: alert.metricName,
384
+ filter: alert.filter,
385
+ groupBy: alert.groupBy,
386
+ groupMode: alert.groupMode,
387
+ aggregation: alert.aggregation,
388
+ windowMinutes: alert.windowMinutes,
389
+ });
390
+ return summarizeEvaluation(alert, evaluation);
391
+ }
superlog/apps/api/src/alerts.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import { db, schema } from "@superlog/db";
3
+ import { eq } from "drizzle-orm";
4
+ import type { Context, Hono } from "hono";
5
+ import { HTTPException } from "hono/http-exception";
6
+ import {
7
+ alertInputSchema,
8
+ createAlertRecord,
9
+ deleteAlertRecord,
10
+ getAlertWithFirings,
11
+ listAlertsForProject,
12
+ previewAlert,
13
+ testAlertById,
14
+ updateAlertRecord,
15
+ } from "./alerts-service.js";
16
+ import { resolveActiveOrgContext } from "./org-context.js";
17
+
18
+ type Vars = { userId: string; orgId: string | null };
19
+
20
+ export { evaluateAlertQuery } from "./alerts-service.js";
21
+
22
+ export function mountAlerts(app: Hono<{ Variables: Vars }>, opts: { ch: ClickHouseClient }) {
23
+ const requireAccess = async (c: Context<{ Variables: Vars }>, projectId: string) => {
24
+ const project = await db.query.projects.findFirst({
25
+ where: eq(schema.projects.id, projectId),
26
+ });
27
+ if (!project) throw new HTTPException(404, { message: "project not found" });
28
+ const ctx = await resolveActiveOrgContext({
29
+ userId: c.var.userId,
30
+ preferredOrgId: c.var.orgId,
31
+ });
32
+ if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
33
+ return { project, user: ctx.user };
34
+ };
35
+
36
+ app.get("/api/projects/:projectId/alerts", async (c) => {
37
+ const projectId = c.req.param("projectId");
38
+ await requireAccess(c, projectId);
39
+ return c.json(await listAlertsForProject(projectId));
40
+ });
41
+
42
+ app.post("/api/projects/:projectId/alerts", async (c) => {
43
+ const projectId = c.req.param("projectId");
44
+ const { user } = await requireAccess(c, projectId);
45
+ const body = await c.req.json().catch(() => ({}));
46
+ const parsed = alertInputSchema.safeParse(body);
47
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
48
+ return c.json(await createAlertRecord(projectId, user.id, parsed.data));
49
+ });
50
+
51
+ app.get("/api/projects/:projectId/alerts/:id", async (c) => {
52
+ const projectId = c.req.param("projectId");
53
+ const id = c.req.param("id");
54
+ await requireAccess(c, projectId);
55
+ const alert = await getAlertWithFirings(projectId, id);
56
+ if (!alert) throw new HTTPException(404, { message: "alert not found" });
57
+ return c.json(alert);
58
+ });
59
+
60
+ app.patch("/api/projects/:projectId/alerts/:id", async (c) => {
61
+ const projectId = c.req.param("projectId");
62
+ const id = c.req.param("id");
63
+ await requireAccess(c, projectId);
64
+ const body = await c.req.json().catch(() => ({}));
65
+ const parsed = alertInputSchema.partial().safeParse(body);
66
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
67
+ const updated = await updateAlertRecord(projectId, id, parsed.data);
68
+ if (!updated) throw new HTTPException(404, { message: "alert not found" });
69
+ return c.json(updated);
70
+ });
71
+
72
+ app.delete("/api/projects/:projectId/alerts/:id", async (c) => {
73
+ const projectId = c.req.param("projectId");
74
+ const id = c.req.param("id");
75
+ const { user } = await requireAccess(c, projectId);
76
+ await deleteAlertRecord(projectId, id, user.id);
77
+ return c.json({ ok: true });
78
+ });
79
+
80
+ app.post("/api/projects/:projectId/alerts/preview", async (c) => {
81
+ const projectId = c.req.param("projectId");
82
+ await requireAccess(c, projectId);
83
+ const body = await c.req.json().catch(() => ({}));
84
+ const parsed = alertInputSchema.safeParse(body);
85
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
86
+ return c.json(await previewAlert(opts.ch, projectId, parsed.data));
87
+ });
88
+
89
+ app.post("/api/projects/:projectId/alerts/:id/test", async (c) => {
90
+ const projectId = c.req.param("projectId");
91
+ const id = c.req.param("id");
92
+ await requireAccess(c, projectId);
93
+ const result = await testAlertById(opts.ch, projectId, id);
94
+ if (!result) throw new HTTPException(404, { message: "alert not found" });
95
+ return c.json(result);
96
+ });
97
+ }
superlog/apps/api/src/auth.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db, schema } from "@superlog/db";
2
+ import { betterAuth } from "better-auth";
3
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
+ import { admin, organization } from "better-auth/plugins";
5
+ import { autumn } from "autumn-js/better-auth";
6
+ import { eq } from "drizzle-orm";
7
+ import {
8
+ orgInvitationEmailBody,
9
+ passwordResetEmailBody,
10
+ sendEmail,
11
+ verificationEmailBody,
12
+ } from "./email.js";
13
+
14
+ // Better Auth server config. Mounted at /api/auth/* by apps/api/src/index.ts.
15
+ //
16
+ // Schema mapping: we reuse our existing `users`, `orgs`, `org_members` tables
17
+ // instead of letting Better Auth own parallel ones. The drizzle adapter
18
+ // accepts a `schema` map keyed by the model names BA expects. The
19
+ // organization plugin's `member.organizationId` and `invitation.organizationId`
20
+ // columns live on the `orgId` TS property in our schema, so we map those
21
+ // explicitly via the plugin's `schema.X.fields` config.
22
+ //
23
+ // Identity is UUID end-to-end: `advanced.database.generateId` makes BA emit
24
+ // UUIDs for new rows so foreign keys to our existing uuid columns line up.
25
+
26
+ const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
27
+ const API_ORIGIN = process.env.BETTER_AUTH_URL ?? "http://localhost:4100";
28
+
29
+ // Derive the parent domain that web and api share so the OAuth state cookie
30
+ // is scoped to `.<eTLD+1>` instead of pinned to the api host. For prod that's
31
+ // `.superlog.sh`; for localhost / .superlog.localhost it's `undefined`
32
+ // because browsers won't honour Domain= on a TLD or localhost.
33
+ function sharedCookieDomain(): string | undefined {
34
+ try {
35
+ const apiHost = new URL(API_ORIGIN).hostname;
36
+ const webHost = new URL(WEB_ORIGIN).hostname;
37
+ if (apiHost === webHost) return undefined; // same host, no cross-sub needed
38
+ if (apiHost === "localhost" || webHost === "localhost") return undefined;
39
+ if (apiHost.endsWith(".localhost") || webHost.endsWith(".localhost")) return undefined;
40
+ // Trim to the longest common dotted suffix shared by both hosts. For
41
+ // api.superlog.sh / superlog.sh that yields "superlog.sh".
42
+ const a = apiHost.split(".");
43
+ const b = webHost.split(".");
44
+ const parts: string[] = [];
45
+ while (a.length && b.length && a[a.length - 1] === b[b.length - 1]) {
46
+ parts.unshift(a.pop() as string);
47
+ b.pop();
48
+ }
49
+ if (parts.length < 2) return undefined; // needs at least eTLD+1
50
+ return `.${parts.join(".")}`;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ const COOKIE_DOMAIN = sharedCookieDomain();
57
+
58
+ // Autumn billing plugin. Maps each org (a Better Auth "organization") to an
59
+ // Autumn customer, auto-created on first sign-in, so billing is per-org. Only
60
+ // enabled when AUTUMN_SECRET_KEY is present, so local dev / worktrees without
61
+ // billing config keep working — same opt-in pattern as the social providers.
62
+ const autumnPlugins = process.env.AUTUMN_SECRET_KEY
63
+ ? [autumn({ customerScope: "organization" })]
64
+ : [];
65
+
66
+ // Fail loud and early if the signing secret is missing. Better Auth doesn't
67
+ // throw on undefined `secret` — depending on the build it falls back to a
68
+ // fresh random value per process start, which silently invalidates every
69
+ // session on every deploy. Match the previous CLERK_SECRET_KEY guard so
70
+ // missing config surfaces as a startup error, not a slow-burn outage.
71
+ const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
72
+ if (!BETTER_AUTH_SECRET) throw new Error("BETTER_AUTH_SECRET is required");
73
+
74
+ // Both providers stay disabled when their client IDs are missing — Better
75
+ // Auth's `socialProviders` map only enables what's present, so dropping the
76
+ // keys here is enough to hide the buttons in dev without breaking sign-up.
77
+ const googleProvider =
78
+ process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
79
+ ? {
80
+ clientId: process.env.GOOGLE_CLIENT_ID,
81
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
82
+ }
83
+ : undefined;
84
+
85
+ const githubProvider =
86
+ process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
87
+ ? {
88
+ clientId: process.env.GITHUB_CLIENT_ID,
89
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
90
+ // GitHub returns email: null in /user when the address is set to
91
+ // "Keep my email addresses private". user:email scope lets Better Auth
92
+ // fall back to /user/emails for the verified primary. Has no effect on
93
+ // GitHub Apps (which derive scopes from the App's User permissions) —
94
+ // for those, enable the "Email addresses" user permission on the App.
95
+ scope: ["user:email", "read:user"],
96
+ }
97
+ : undefined;
98
+
99
+ // Picks the org to make active for a session. Prefers an explicit override
100
+ // (set via the org plugin's setActive API) then falls back to the user's
101
+ // first membership. Returns null if the user has no memberships — that's an
102
+ // error state callers can decide how to handle.
103
+ async function pickActiveOrgId(userId: string): Promise<string | null> {
104
+ const membership = await db.query.orgMembers.findFirst({
105
+ where: eq(schema.orgMembers.userId, userId),
106
+ });
107
+ return membership?.orgId ?? null;
108
+ }
109
+
110
+ export const auth = betterAuth({
111
+ baseURL: API_ORIGIN,
112
+ secret: BETTER_AUTH_SECRET,
113
+ trustedOrigins: [WEB_ORIGIN],
114
+ database: drizzleAdapter(db, {
115
+ provider: "pg",
116
+ schema: {
117
+ user: schema.users,
118
+ session: schema.sessions,
119
+ account: schema.accounts,
120
+ verification: schema.verifications,
121
+ organization: schema.orgs,
122
+ member: schema.orgMembers,
123
+ invitation: schema.invitations,
124
+ },
125
+ }),
126
+ advanced: {
127
+ database: {
128
+ generateId: () => crypto.randomUUID(),
129
+ },
130
+ // Web and API often live on different subdomains of the same parent
131
+ // domain (e.g. `superlog.sh` + `api.superlog.sh`). The OAuth state
132
+ // cookie has to round-trip from BA's `/api/auth/sign-in/social` response
133
+ // through the provider and back to `/api/auth/callback/<provider>`; if
134
+ // the cookie is pinned to the API host instead of the parent domain it
135
+ // can fail to ride along on some browsers and BA returns
136
+ // `State mismatch: verification not found`. When the hosts share an
137
+ // eTLD+1, scope to the parent. localhost / *.localhost are skipped
138
+ // because browsers reject Domain= on those.
139
+ ...(COOKIE_DOMAIN
140
+ ? {
141
+ crossSubDomainCookies: { enabled: true, domain: COOKIE_DOMAIN },
142
+ defaultCookieAttributes: { sameSite: "lax" as const, secure: true },
143
+ }
144
+ : {}),
145
+ },
146
+ emailAndPassword: {
147
+ enabled: true,
148
+ // Sign-ups still land logged-in immediately to keep dev/worktree flows
149
+ // fast — the verification email is still sent (see `emailVerification`
150
+ // below) so the user can confirm at their leisure. Flip this to true once
151
+ // we want to gate /api/* access on a verified address.
152
+ requireEmailVerification: false,
153
+ sendResetPassword: async ({ user, url }) => {
154
+ const body = passwordResetEmailBody(url);
155
+ await sendEmail({ to: user.email, subject: "Reset your Superlog password", ...body });
156
+ },
157
+ },
158
+ emailVerification: {
159
+ sendOnSignUp: true,
160
+ autoSignInAfterVerification: true,
161
+ sendVerificationEmail: async ({ user, url }) => {
162
+ const body = verificationEmailBody(url);
163
+ await sendEmail({ to: user.email, subject: "Confirm your email for Superlog", ...body });
164
+ },
165
+ },
166
+ socialProviders: {
167
+ ...(googleProvider ? { google: googleProvider } : {}),
168
+ ...(githubProvider ? { github: githubProvider } : {}),
169
+ },
170
+ plugins: [
171
+ organization({
172
+ schema: {
173
+ // Our `org_members` table uses `orgId` (TS) / `org_id` (SQL) instead
174
+ // of Better Auth's expected `organizationId`. Same for `invitations`.
175
+ member: {
176
+ fields: { organizationId: "orgId" },
177
+ },
178
+ invitation: {
179
+ fields: { organizationId: "orgId" },
180
+ },
181
+ },
182
+ sendInvitationEmail: async ({ id, email, role, organization, inviter }) => {
183
+ const url = `${WEB_ORIGIN}/accept-invitation?id=${encodeURIComponent(id)}`;
184
+ const body = orgInvitationEmailBody({
185
+ url,
186
+ orgName: organization.name,
187
+ inviterEmail: inviter.user.email,
188
+ inviterName: inviter.user.name,
189
+ role,
190
+ });
191
+ await sendEmail({
192
+ to: email,
193
+ subject: `You're invited to ${organization.name} on Superlog`,
194
+ ...body,
195
+ });
196
+ },
197
+ }),
198
+ admin(),
199
+ ...autumnPlugins,
200
+ ],
201
+ databaseHooks: {
202
+ session: {
203
+ create: {
204
+ before: async (session) => {
205
+ // Seed the active org so the first request after sign-in already
206
+ // has org context. If the user has no memberships yet (e.g. on a
207
+ // bootstrap race) leave it null; callers handle that.
208
+ if (session.activeOrganizationId) return { data: session };
209
+ const activeOrganizationId = await pickActiveOrgId(session.userId);
210
+ return { data: { ...session, activeOrganizationId } };
211
+ },
212
+ },
213
+ },
214
+ },
215
+ });
216
+
217
+ export type Auth = typeof auth;
superlog/apps/api/src/boot-migrations.test.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { shouldRunMigrationsOnBoot } from "./boot-migrations.js";
4
+
5
+ test("defaults to running migrations on boot when unset", () => {
6
+ assert.equal(shouldRunMigrationsOnBoot({} as NodeJS.ProcessEnv), true);
7
+ });
8
+
9
+ test("runs on boot when explicitly enabled", () => {
10
+ assert.equal(
11
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "true" } as NodeJS.ProcessEnv),
12
+ true,
13
+ );
14
+ });
15
+
16
+ test("skips boot migrations only when explicitly set to 'false'", () => {
17
+ assert.equal(
18
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "false" } as NodeJS.ProcessEnv),
19
+ false,
20
+ );
21
+ });
22
+
23
+ test("any non-'false' value still runs on boot", () => {
24
+ assert.equal(
25
+ shouldRunMigrationsOnBoot({ RUN_MIGRATIONS_ON_BOOT: "0" } as NodeJS.ProcessEnv),
26
+ true,
27
+ );
28
+ });
superlog/apps/api/src/boot-migrations.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Whether the API process should apply database migrations on boot.
2
+ //
3
+ // In a managed deployment, migrations should be applied by a dedicated,
4
+ // gated step that runs as a schema-owner role *before* any service rolls out.
5
+ // The long-running API process should then connect as a DML-only role that
6
+ // can't run DDL on boot — applying DDL from a rolling app deploy can crash-loop
7
+ // the service. Such a deployment sets RUN_MIGRATIONS_ON_BOOT=false.
8
+ //
9
+ // Locally (overmind, plain `pnpm dev`) there's no separate migrate step, so the
10
+ // default is to run on boot — preserving the existing developer experience.
11
+ // Worktree bootstrap already migrates explicitly, so this only affects the
12
+ // main-checkout dev loop.
13
+ export function shouldRunMigrationsOnBoot(env: NodeJS.ProcessEnv): boolean {
14
+ return env.RUN_MIGRATIONS_ON_BOOT !== "false";
15
+ }
superlog/apps/api/src/dashboards-service.ts ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db, schema } from "@superlog/db";
2
+ import { and, asc, eq } from "drizzle-orm";
3
+ import { z } from "zod";
4
+
5
+ export const dashboardWidgetTypeSchema = z.enum([
6
+ "timeseries_count",
7
+ "timeseries_metric",
8
+ "trace_table",
9
+ "log_table",
10
+ "markdown",
11
+ ]);
12
+
13
+ const resourceAttrSchema = z.object({
14
+ key: z.string().min(1).max(200),
15
+ value: z.string().max(500),
16
+ op: z.enum(["eq", "neq", "not_contains"]).optional(),
17
+ });
18
+
19
+ export const dashboardWidgetConfigSchema = z.object({
20
+ source: z.enum(["logs", "traces"]).optional(),
21
+ filter: z.object({
22
+ resourceAttrs: z.array(resourceAttrSchema).max(50).optional(),
23
+ }),
24
+ groupBy: z.string().max(200).optional(),
25
+ metricName: z.string().max(200).optional(),
26
+ aggregation: z.enum(["sum", "avg", "min", "max", "p95", "p99"]).optional(),
27
+ limit: z.number().int().positive().max(500).optional(),
28
+ chartType: z.enum(["line", "bar"]).optional(),
29
+ showXAxis: z.boolean().optional(),
30
+ showYAxis: z.boolean().optional(),
31
+ showLegend: z.boolean().optional(),
32
+ legendPosition: z.enum(["side", "bottom"]).optional(),
33
+ markdown: z.string().max(20_000).optional(),
34
+ });
35
+
36
+ export const dashboardWidgetLayoutSchema = z.object({
37
+ x: z.number().int().min(0).max(48),
38
+ y: z.number().int().min(0).max(100000),
39
+ w: z.number().int().min(1).max(48),
40
+ h: z.number().int().min(1).max(100),
41
+ });
42
+
43
+ export const dashboardCreateSchema = z.object({ name: z.string().min(1).max(120) });
44
+ export const dashboardUpdateSchema = z.object({ name: z.string().min(1).max(120) });
45
+
46
+ export const dashboardWidgetCreateSchema = z.object({
47
+ type: dashboardWidgetTypeSchema,
48
+ title: z.string().min(1).max(200),
49
+ config: dashboardWidgetConfigSchema,
50
+ layout: dashboardWidgetLayoutSchema,
51
+ });
52
+
53
+ export const dashboardWidgetUpdateSchema = z.object({
54
+ type: dashboardWidgetTypeSchema.optional(),
55
+ title: z.string().min(1).max(200).optional(),
56
+ config: dashboardWidgetConfigSchema.optional(),
57
+ layout: dashboardWidgetLayoutSchema.optional(),
58
+ });
59
+
60
+ export type DashboardCreateInput = z.infer<typeof dashboardCreateSchema>;
61
+ export type DashboardUpdateInput = z.infer<typeof dashboardUpdateSchema>;
62
+ export type DashboardWidgetCreateInput = z.infer<typeof dashboardWidgetCreateSchema>;
63
+ export type DashboardWidgetUpdateInput = z.infer<typeof dashboardWidgetUpdateSchema>;
64
+
65
+ const slugFromName = (name: string) =>
66
+ name
67
+ .toLowerCase()
68
+ .trim()
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-+|-+$/g, "")
71
+ .slice(0, 60) || "dashboard";
72
+
73
+ export async function listDashboardsForProject(projectId: string): Promise<schema.Dashboard[]> {
74
+ return db.query.dashboards.findMany({
75
+ where: eq(schema.dashboards.projectId, projectId),
76
+ orderBy: [asc(schema.dashboards.name)],
77
+ });
78
+ }
79
+
80
+ export async function getDashboardWithWidgets(
81
+ projectId: string,
82
+ id: string,
83
+ ): Promise<(schema.Dashboard & { widgets: schema.DashboardWidget[] }) | null> {
84
+ const dashboard = await db.query.dashboards.findFirst({
85
+ where: and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)),
86
+ });
87
+ if (!dashboard) return null;
88
+ const widgets = await db.query.dashboardWidgets.findMany({
89
+ where: eq(schema.dashboardWidgets.dashboardId, id),
90
+ orderBy: [asc(schema.dashboardWidgets.position)],
91
+ });
92
+ return { ...dashboard, widgets };
93
+ }
94
+
95
+ export async function createDashboard(
96
+ projectId: string,
97
+ userId: string,
98
+ input: DashboardCreateInput,
99
+ ): Promise<schema.Dashboard> {
100
+ const baseSlug = slugFromName(input.name);
101
+ let slug = baseSlug;
102
+ for (let i = 2; i < 100; i++) {
103
+ const existing = await db.query.dashboards.findFirst({
104
+ where: and(eq(schema.dashboards.projectId, projectId), eq(schema.dashboards.slug, slug)),
105
+ });
106
+ if (!existing) break;
107
+ slug = `${baseSlug}-${i}`;
108
+ }
109
+ const inserted = await db
110
+ .insert(schema.dashboards)
111
+ .values({ projectId, name: input.name, slug, createdBy: userId })
112
+ .returning();
113
+ const row = inserted[0];
114
+ if (!row) throw new Error("dashboards insert returned no rows");
115
+ return row;
116
+ }
117
+
118
+ export async function updateDashboard(
119
+ projectId: string,
120
+ id: string,
121
+ input: DashboardUpdateInput,
122
+ ): Promise<schema.Dashboard | null> {
123
+ const updated = await db
124
+ .update(schema.dashboards)
125
+ .set({ name: input.name, updatedAt: new Date() })
126
+ .where(and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)))
127
+ .returning();
128
+ return updated[0] ?? null;
129
+ }
130
+
131
+ export async function deleteDashboard(projectId: string, id: string): Promise<void> {
132
+ await db
133
+ .delete(schema.dashboards)
134
+ .where(and(eq(schema.dashboards.id, id), eq(schema.dashboards.projectId, projectId)));
135
+ }
136
+
137
+ async function ensureDashboardOwned(
138
+ projectId: string,
139
+ dashboardId: string,
140
+ ): Promise<schema.Dashboard | null> {
141
+ return (
142
+ (await db.query.dashboards.findFirst({
143
+ where: and(eq(schema.dashboards.id, dashboardId), eq(schema.dashboards.projectId, projectId)),
144
+ })) ?? null
145
+ );
146
+ }
147
+
148
+ export async function addDashboardWidget(
149
+ projectId: string,
150
+ dashboardId: string,
151
+ input: DashboardWidgetCreateInput,
152
+ ): Promise<schema.DashboardWidget | null> {
153
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
154
+ if (!dashboard) return null;
155
+ const existing = await db.query.dashboardWidgets.findMany({
156
+ where: eq(schema.dashboardWidgets.dashboardId, dashboardId),
157
+ });
158
+ const nextPosition = existing.reduce((m, w) => Math.max(m, w.position), -1) + 1;
159
+ const inserted = await db
160
+ .insert(schema.dashboardWidgets)
161
+ .values({
162
+ dashboardId,
163
+ type: input.type,
164
+ title: input.title,
165
+ config: input.config,
166
+ layout: input.layout,
167
+ position: nextPosition,
168
+ })
169
+ .returning();
170
+ await db
171
+ .update(schema.dashboards)
172
+ .set({ updatedAt: new Date() })
173
+ .where(eq(schema.dashboards.id, dashboardId));
174
+ return inserted[0] ?? null;
175
+ }
176
+
177
+ export async function updateDashboardWidget(
178
+ projectId: string,
179
+ dashboardId: string,
180
+ widgetId: string,
181
+ input: DashboardWidgetUpdateInput,
182
+ ): Promise<schema.DashboardWidget | null> {
183
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
184
+ if (!dashboard) return null;
185
+ const updated = await db
186
+ .update(schema.dashboardWidgets)
187
+ .set({
188
+ ...(input.type !== undefined ? { type: input.type } : {}),
189
+ ...(input.title !== undefined ? { title: input.title } : {}),
190
+ ...(input.config !== undefined ? { config: input.config } : {}),
191
+ ...(input.layout !== undefined ? { layout: input.layout } : {}),
192
+ updatedAt: new Date(),
193
+ })
194
+ .where(
195
+ and(
196
+ eq(schema.dashboardWidgets.id, widgetId),
197
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
198
+ ),
199
+ )
200
+ .returning();
201
+ if (!updated[0]) return null;
202
+ await db
203
+ .update(schema.dashboards)
204
+ .set({ updatedAt: new Date() })
205
+ .where(eq(schema.dashboards.id, dashboardId));
206
+ return updated[0];
207
+ }
208
+
209
+ export async function deleteDashboardWidget(
210
+ projectId: string,
211
+ dashboardId: string,
212
+ widgetId: string,
213
+ ): Promise<boolean> {
214
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
215
+ if (!dashboard) return false;
216
+ await db
217
+ .delete(schema.dashboardWidgets)
218
+ .where(
219
+ and(
220
+ eq(schema.dashboardWidgets.id, widgetId),
221
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
222
+ ),
223
+ );
224
+ return true;
225
+ }
226
+
227
+ export async function updateDashboardLayout(
228
+ projectId: string,
229
+ dashboardId: string,
230
+ widgets: { id: string; layout: z.infer<typeof dashboardWidgetLayoutSchema> }[],
231
+ ): Promise<boolean> {
232
+ const dashboard = await ensureDashboardOwned(projectId, dashboardId);
233
+ if (!dashboard) return false;
234
+ await Promise.all(
235
+ widgets.map((w) =>
236
+ db
237
+ .update(schema.dashboardWidgets)
238
+ .set({ layout: w.layout, updatedAt: new Date() })
239
+ .where(
240
+ and(
241
+ eq(schema.dashboardWidgets.id, w.id),
242
+ eq(schema.dashboardWidgets.dashboardId, dashboardId),
243
+ ),
244
+ ),
245
+ ),
246
+ );
247
+ await db
248
+ .update(schema.dashboards)
249
+ .set({ updatedAt: new Date() })
250
+ .where(eq(schema.dashboards.id, dashboardId));
251
+ return true;
252
+ }
superlog/apps/api/src/dashboards.ts ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db, schema } from "@superlog/db";
2
+ import { eq } from "drizzle-orm";
3
+ import type { Context, Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import { z } from "zod";
6
+ import {
7
+ addDashboardWidget,
8
+ createDashboard,
9
+ dashboardCreateSchema,
10
+ dashboardUpdateSchema,
11
+ dashboardWidgetCreateSchema,
12
+ dashboardWidgetLayoutSchema,
13
+ dashboardWidgetUpdateSchema,
14
+ deleteDashboard,
15
+ deleteDashboardWidget,
16
+ getDashboardWithWidgets,
17
+ listDashboardsForProject,
18
+ updateDashboard,
19
+ updateDashboardLayout,
20
+ updateDashboardWidget,
21
+ } from "./dashboards-service.js";
22
+ import { resolveActiveOrgContext } from "./org-context.js";
23
+
24
+ type Vars = { userId: string; orgId: string | null };
25
+
26
+ export function mountDashboards(app: Hono<{ Variables: Vars }>) {
27
+ const requireAccess = async (c: Context<{ Variables: Vars }>, projectId: string) => {
28
+ const project = await db.query.projects.findFirst({
29
+ where: eq(schema.projects.id, projectId),
30
+ });
31
+ if (!project) throw new HTTPException(404, { message: "project not found" });
32
+ const ctx = await resolveActiveOrgContext({
33
+ userId: c.var.userId,
34
+ preferredOrgId: c.var.orgId,
35
+ });
36
+ if (project.orgId !== ctx.org.id) throw new HTTPException(403, { message: "forbidden" });
37
+ return { project, user: ctx.user };
38
+ };
39
+
40
+ app.get("/api/projects/:projectId/dashboards", async (c) => {
41
+ const projectId = c.req.param("projectId");
42
+ await requireAccess(c, projectId);
43
+ return c.json(await listDashboardsForProject(projectId));
44
+ });
45
+
46
+ app.post("/api/projects/:projectId/dashboards", async (c) => {
47
+ const projectId = c.req.param("projectId");
48
+ const { user } = await requireAccess(c, projectId);
49
+ const body = await c.req.json().catch(() => ({}));
50
+ const parsed = dashboardCreateSchema.safeParse(body);
51
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
52
+ return c.json(await createDashboard(projectId, user.id, parsed.data));
53
+ });
54
+
55
+ app.get("/api/projects/:projectId/dashboards/:id", async (c) => {
56
+ const projectId = c.req.param("projectId");
57
+ const id = c.req.param("id");
58
+ await requireAccess(c, projectId);
59
+ const dashboard = await getDashboardWithWidgets(projectId, id);
60
+ if (!dashboard) throw new HTTPException(404, { message: "dashboard not found" });
61
+ return c.json(dashboard);
62
+ });
63
+
64
+ app.patch("/api/projects/:projectId/dashboards/:id", async (c) => {
65
+ const projectId = c.req.param("projectId");
66
+ const id = c.req.param("id");
67
+ await requireAccess(c, projectId);
68
+ const body = await c.req.json().catch(() => ({}));
69
+ const parsed = dashboardUpdateSchema.safeParse(body);
70
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
71
+ const updated = await updateDashboard(projectId, id, parsed.data);
72
+ if (!updated) throw new HTTPException(404, { message: "dashboard not found" });
73
+ return c.json(updated);
74
+ });
75
+
76
+ app.delete("/api/projects/:projectId/dashboards/:id", async (c) => {
77
+ const projectId = c.req.param("projectId");
78
+ const id = c.req.param("id");
79
+ await requireAccess(c, projectId);
80
+ await deleteDashboard(projectId, id);
81
+ return c.json({ ok: true });
82
+ });
83
+
84
+ app.post("/api/projects/:projectId/dashboards/:id/widgets", async (c) => {
85
+ const projectId = c.req.param("projectId");
86
+ const id = c.req.param("id");
87
+ await requireAccess(c, projectId);
88
+ const body = await c.req.json().catch(() => ({}));
89
+ const parsed = dashboardWidgetCreateSchema.safeParse(body);
90
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
91
+ const widget = await addDashboardWidget(projectId, id, parsed.data);
92
+ if (!widget) throw new HTTPException(404, { message: "dashboard not found" });
93
+ return c.json(widget);
94
+ });
95
+
96
+ app.patch("/api/projects/:projectId/dashboards/:id/widgets/:widgetId", async (c) => {
97
+ const projectId = c.req.param("projectId");
98
+ const id = c.req.param("id");
99
+ const widgetId = c.req.param("widgetId");
100
+ await requireAccess(c, projectId);
101
+ const body = await c.req.json().catch(() => ({}));
102
+ const parsed = dashboardWidgetUpdateSchema.safeParse(body);
103
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
104
+ const widget = await updateDashboardWidget(projectId, id, widgetId, parsed.data);
105
+ if (!widget) throw new HTTPException(404, { message: "widget not found" });
106
+ return c.json(widget);
107
+ });
108
+
109
+ app.patch("/api/projects/:projectId/dashboards/:id/layout", async (c) => {
110
+ const projectId = c.req.param("projectId");
111
+ const id = c.req.param("id");
112
+ await requireAccess(c, projectId);
113
+ const body = await c.req.json().catch(() => ({}));
114
+ const parsed = z
115
+ .object({
116
+ widgets: z
117
+ .array(z.object({ id: z.string().uuid(), layout: dashboardWidgetLayoutSchema }))
118
+ .max(200),
119
+ })
120
+ .safeParse(body);
121
+ if (!parsed.success) throw new HTTPException(400, { message: "invalid body" });
122
+ const ok = await updateDashboardLayout(projectId, id, parsed.data.widgets);
123
+ if (!ok) throw new HTTPException(404, { message: "dashboard not found" });
124
+ return c.json({ ok: true });
125
+ });
126
+
127
+ app.delete("/api/projects/:projectId/dashboards/:id/widgets/:widgetId", async (c) => {
128
+ const projectId = c.req.param("projectId");
129
+ const id = c.req.param("id");
130
+ const widgetId = c.req.param("widgetId");
131
+ await requireAccess(c, projectId);
132
+ const ok = await deleteDashboardWidget(projectId, id, widgetId);
133
+ if (!ok) throw new HTTPException(404, { message: "dashboard not found" });
134
+ return c.json({ ok: true });
135
+ });
136
+ }
superlog/apps/api/src/email.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Resend } from "resend";
2
+ import { logger } from "./logger.js";
3
+
4
+ // Thin Resend wrapper used by Better Auth for verification + password reset
5
+ // emails. Dev fallback: when RESEND_API_KEY is unset, log the body to stderr
6
+ // instead of throwing — that lets worktree sign-ups continue to work without
7
+ // production-level email credentials.
8
+
9
+ const log = logger.child({ scope: "email" });
10
+
11
+ const FROM = process.env.SUPERLOG_FROM_EMAIL ?? "Superlog <no-reply@superlog.sh>";
12
+ const REPLY_TO = process.env.SUPERLOG_REPLY_TO_EMAIL;
13
+
14
+ let client: Resend | null = null;
15
+ function getClient(): Resend | null {
16
+ if (client) return client;
17
+ const key = process.env.RESEND_API_KEY;
18
+ if (!key) return null;
19
+ client = new Resend(key);
20
+ return client;
21
+ }
22
+
23
+ export type EmailMessage = {
24
+ to: string;
25
+ subject: string;
26
+ html: string;
27
+ text: string;
28
+ };
29
+
30
+ export async function sendEmail(message: EmailMessage): Promise<void> {
31
+ const c = getClient();
32
+ if (!c) {
33
+ log.warn(
34
+ { to: message.to, subject: message.subject },
35
+ "RESEND_API_KEY not set — printing email body instead of sending",
36
+ );
37
+ log.info({ body: message.text }, "email body (dev fallback)");
38
+ return;
39
+ }
40
+ const res = await c.emails.send({
41
+ from: FROM,
42
+ to: [message.to],
43
+ replyTo: REPLY_TO,
44
+ subject: message.subject,
45
+ html: message.html,
46
+ text: message.text,
47
+ });
48
+ if (res.error) {
49
+ log.error({ err: res.error, to: message.to }, "resend send failed");
50
+ throw new Error(`resend: ${res.error.message ?? "unknown"}`);
51
+ }
52
+ }
53
+
54
+ function escape(value: string): string {
55
+ return value
56
+ .replace(/&/g, "&amp;")
57
+ .replace(/</g, "&lt;")
58
+ .replace(/>/g, "&gt;")
59
+ .replace(/"/g, "&quot;");
60
+ }
61
+
62
+ export function verificationEmailBody(url: string): { html: string; text: string } {
63
+ const safe = escape(url);
64
+ return {
65
+ text: `Confirm your email to start using Superlog.\n\n${url}\n\nIf you didn't sign up, you can ignore this email.`,
66
+ html: `<p>Confirm your email to start using Superlog.</p>
67
+ <p><a href="${safe}">${safe}</a></p>
68
+ <p style="color:#888">If you didn't sign up, you can ignore this email.</p>`,
69
+ };
70
+ }
71
+
72
+ export function passwordResetEmailBody(url: string): { html: string; text: string } {
73
+ const safe = escape(url);
74
+ return {
75
+ text: `Reset your Superlog password.\n\n${url}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`,
76
+ html: `<p>Reset your Superlog password.</p>
77
+ <p><a href="${safe}">${safe}</a></p>
78
+ <p style="color:#888">This link expires in 1 hour. If you didn't request a reset, ignore this email.</p>`,
79
+ };
80
+ }
81
+
82
+ export function orgInvitationEmailBody(args: {
83
+ url: string;
84
+ orgName: string;
85
+ inviterEmail: string;
86
+ inviterName?: string | null;
87
+ role: string;
88
+ }): { html: string; text: string } {
89
+ const safe = escape(args.url);
90
+ const org = escape(args.orgName);
91
+ const inviter = escape(args.inviterName?.trim() ? args.inviterName : args.inviterEmail);
92
+ const role = escape(args.role);
93
+ return {
94
+ text: `${args.inviterName?.trim() || args.inviterEmail} invited you to join ${args.orgName} on Superlog as ${args.role}.\n\nAccept: ${args.url}\n\nIf you weren't expecting this invite, you can ignore it.`,
95
+ html: `<p>${inviter} invited you to join <strong>${org}</strong> on Superlog as ${role}.</p>
96
+ <p><a href="${safe}">Accept invitation</a></p>
97
+ <p style="color:#888">If you weren't expecting this invite, you can ignore it.</p>`,
98
+ };
99
+ }
superlog/apps/api/src/env.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { config as loadDotenv } from "dotenv";
2
+ const portlessPort = process.env.PORTLESS_URL ? process.env.PORT : undefined;
3
+ loadDotenv();
4
+ loadDotenv({ path: ".env.local", override: true });
5
+ if (process.env.SUPERLOG_ENV_FILE) {
6
+ loadDotenv({ path: process.env.SUPERLOG_ENV_FILE, override: true });
7
+ }
8
+ if (portlessPort) process.env.PORT = portlessPort;
superlog/apps/api/src/feedback.ts ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db, schema } from "@superlog/db";
2
+ import { and, eq } from "drizzle-orm";
3
+ import type { Hono } from "hono";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import { auth } from "./auth.js";
6
+ import { logger } from "./logger.js";
7
+
8
+ const log = logger.child({ scope: "feedback" });
9
+
10
+ const MAX_BODY_LEN = 8_000;
11
+ const FEEDBACK_KINDS = new Set<schema.FeedbackKind>(["incident", "issue", "pr"]);
12
+
13
+ // Public + authed routes share one mount because the PR-link page must be
14
+ // reachable without a session (external GitHub PR participants).
15
+ //
16
+ // /api/feedback (authed) is for the in-product dialog on incident/issue
17
+ // drawers — the session middleware in index.ts wraps it. /feedback/pr/...
18
+ // is mounted before the session middleware so it's open to anonymous
19
+ // submitters. The github.ts webhook handler inserts feedback rows directly
20
+ // via the helper below; same for the Slack interactivity handler.
21
+ //
22
+ // Every insert path funnels through `recordFeedback` so the Slack notifier
23
+ // and admin-inbox bookkeeping stay in one place.
24
+
25
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
26
+ export function mountFeedbackPublic(app: Hono<any>): void {
27
+ // Anonymous PR-link submissions. We accept these without a session because
28
+ // the link is included in PR descriptions on customer repos, and many
29
+ // commenters won't have Superlog accounts. Rate-limiting is intentionally
30
+ // out-of-scope for v1 — the form is one click deep, the admin inbox is
31
+ // staff-only, and we can lock it down if it gets abused.
32
+ app.post("/feedback/pr/:owner/:repo/:number", async (c) => {
33
+ const owner = c.req.param("owner");
34
+ const repo = c.req.param("repo");
35
+ const number = Number(c.req.param("number"));
36
+ if (!owner || !repo || !Number.isFinite(number) || number <= 0) {
37
+ throw new HTTPException(400, { message: "invalid pr ref" });
38
+ }
39
+ const body = (await c.req.json().catch(() => ({}))) as {
40
+ body?: unknown;
41
+ githubLogin?: unknown;
42
+ };
43
+ const text = typeof body.body === "string" ? body.body.trim() : "";
44
+ if (!text) throw new HTTPException(400, { message: "body required" });
45
+ if (text.length > MAX_BODY_LEN) {
46
+ throw new HTTPException(400, { message: "body too long" });
47
+ }
48
+ const githubLogin = typeof body.githubLogin === "string" ? body.githubLogin.trim() : "";
49
+
50
+ // Look up the agent PR row if we know about it — best-effort, the
51
+ // feedback is recorded either way. ref_id is the agent_pull_requests.id
52
+ // when we have it, otherwise we fall back to "owner/repo#number" so the
53
+ // admin still has something to click.
54
+ const repoFullName = `${owner}/${repo}`;
55
+ const agentPr = await db.query.agentPullRequests.findFirst({
56
+ where: and(
57
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
58
+ eq(schema.agentPullRequests.prNumber, number),
59
+ ),
60
+ });
61
+
62
+ // Try to grab the session cookie if the user happens to be signed in —
63
+ // that lets us attribute the feedback to a known Superlog user even
64
+ // though the route is public. Best-effort.
65
+ let authorUserId: string | null = null;
66
+ try {
67
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
68
+ authorUserId = session?.user.id ?? null;
69
+ } catch {
70
+ authorUserId = null;
71
+ }
72
+
73
+ await recordFeedback({
74
+ kind: "pr",
75
+ refId: agentPr?.id ?? `${repoFullName}#${number}`,
76
+ refRepo: repoFullName,
77
+ source: "pr_link",
78
+ body: text,
79
+ authorUserId,
80
+ authorExternal: githubLogin ? { githubLogin } : null,
81
+ orgId: null,
82
+ projectId: null,
83
+ });
84
+
85
+ return c.json({ ok: true });
86
+ });
87
+ }
88
+
89
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
90
+ export function mountFeedbackAuthed(app: Hono<any>): void {
91
+ // In-product dialog. The dialog is rendered on incident and issue
92
+ // drawers, so kind is always 'incident' or 'issue'. We trust the caller
93
+ // to pass the correct ref_id (incident or issue UUID); we don't gate on
94
+ // whether the user can actually see that incident — feedback is an
95
+ // unauth'd-by-design signal and the admin inbox is the only thing that
96
+ // reads it.
97
+ app.post("/api/feedback", async (c) => {
98
+ const userId = c.var.userId as string;
99
+ const body = (await c.req.json().catch(() => ({}))) as {
100
+ kind?: unknown;
101
+ refId?: unknown;
102
+ body?: unknown;
103
+ projectId?: unknown;
104
+ };
105
+ const kind = typeof body.kind === "string" ? body.kind : "";
106
+ if (!FEEDBACK_KINDS.has(kind as schema.FeedbackKind) || kind === "pr") {
107
+ // PR feedback comes in via /feedback/pr/* (anonymous) or the github
108
+ // webhook; the in-product dialog only fires on incident/issue pages.
109
+ throw new HTTPException(400, { message: "kind must be 'incident' or 'issue'" });
110
+ }
111
+ const refId = typeof body.refId === "string" ? body.refId : "";
112
+ if (!refId) throw new HTTPException(400, { message: "refId required" });
113
+ const text = typeof body.body === "string" ? body.body.trim() : "";
114
+ if (!text) throw new HTTPException(400, { message: "body required" });
115
+ if (text.length > MAX_BODY_LEN) {
116
+ throw new HTTPException(400, { message: "body too long" });
117
+ }
118
+ // Resolve projectId via lookup before we trust it. A garbage string
119
+ // (non-UUID syntax, or a stale ID whose project was deleted) would
120
+ // otherwise hit the feedback.project_id FK and 500 on insert. We
121
+ // wrap the lookup in try/catch because postgres throws a `22P02`
122
+ // syntax error rather than returning empty for malformed UUIDs.
123
+ const requestedProjectId = typeof body.projectId === "string" ? body.projectId : null;
124
+ let projectId: string | null = null;
125
+ let orgId: string | null = null;
126
+ if (requestedProjectId) {
127
+ const project = await db.query.projects
128
+ .findFirst({ where: eq(schema.projects.id, requestedProjectId) })
129
+ .catch(() => null);
130
+ if (project) {
131
+ projectId = project.id;
132
+ orgId = project.orgId;
133
+ }
134
+ }
135
+
136
+ await recordFeedback({
137
+ kind: kind as "incident" | "issue",
138
+ refId,
139
+ refRepo: null,
140
+ source: "dialog",
141
+ body: text,
142
+ authorUserId: userId,
143
+ authorExternal: null,
144
+ orgId,
145
+ projectId,
146
+ });
147
+
148
+ return c.json({ ok: true });
149
+ });
150
+
151
+ // Staff triage reads the same `feedback` table directly.
152
+ }
153
+
154
+ // Shared insert + notify. Exported so the github webhook handler and Slack
155
+ // view_submission handler can call it directly without re-implementing the
156
+ // shape contract.
157
+ export async function recordFeedback(input: {
158
+ kind: schema.FeedbackKind;
159
+ refId: string;
160
+ refRepo: string | null;
161
+ source: schema.FeedbackSource;
162
+ body: string;
163
+ authorUserId: string | null;
164
+ authorExternal: schema.FeedbackAuthorExternal | null;
165
+ orgId: string | null;
166
+ projectId: string | null;
167
+ }): Promise<void> {
168
+ const [row] = await db
169
+ .insert(schema.feedback)
170
+ .values({
171
+ kind: input.kind,
172
+ refId: input.refId,
173
+ refRepo: input.refRepo,
174
+ source: input.source,
175
+ body: input.body,
176
+ authorUserId: input.authorUserId,
177
+ authorExternal: input.authorExternal,
178
+ orgId: input.orgId,
179
+ projectId: input.projectId,
180
+ })
181
+ .returning();
182
+ log.info(
183
+ {
184
+ feedback_id: row?.id,
185
+ kind: input.kind,
186
+ source: input.source,
187
+ ref_id: input.refId,
188
+ author_user_id: input.authorUserId,
189
+ },
190
+ "feedback recorded",
191
+ );
192
+ if (row) {
193
+ void notifyFeedbackSlack(row).catch((err) => {
194
+ log.warn({ err, feedback_id: row.id }, "feedback slack notify failed");
195
+ });
196
+ }
197
+ }
198
+
199
+ // Posts a one-liner to FEEDBACK_SLACK_WEBHOOK so the team hears about new
200
+ // feedback in real time. Best-effort — failures are logged and swallowed
201
+ // so they never block the insert path. Configure FEEDBACK_SLACK_WEBHOOK to
202
+ // a Slack incoming webhook URL (Slack app → Incoming Webhooks → pick a
203
+ // channel). Leave unset to disable.
204
+ async function notifyFeedbackSlack(row: schema.Feedback): Promise<void> {
205
+ const webhookUrl = process.env.FEEDBACK_SLACK_WEBHOOK;
206
+ if (!webhookUrl) return;
207
+ // Optional staff tooling deep link. Unset -> no deep link.
208
+ const adminOrigin = process.env.ADMIN_WEB_ORIGIN;
209
+ const link = adminOrigin ? `${adminOrigin}/feedback?id=${row.id}` : null;
210
+ const preview = row.body.length > 280 ? `${row.body.slice(0, 277)}…` : row.body;
211
+ let authorEmail: string | null = null;
212
+ if (row.authorUserId) {
213
+ const user = await db.query.users.findFirst({
214
+ where: eq(schema.users.id, row.authorUserId),
215
+ });
216
+ authorEmail = user?.email ?? null;
217
+ }
218
+ const author = authorEmail
219
+ ? authorEmail
220
+ : row.authorExternal?.githubLogin
221
+ ? `@${row.authorExternal.githubLogin} (github)`
222
+ : row.authorExternal?.slackUserId
223
+ ? `<@${row.authorExternal.slackUserId}> (slack)`
224
+ : "anonymous";
225
+ // For PR feedback the refId is either an agent_pull_requests UUID (when
226
+ // we know the PR — happens for PR-comment webhooks and for /feedback/pr/*
227
+ // POSTs that match an agent PR row) or the fallback "owner/repo#number"
228
+ // string from feedback.ts:79 (when the link was hit for a PR we don't
229
+ // track). The UUID case needs the repo name prepended; the fallback case
230
+ // already includes it, so re-prepending makes "owner/repo#…" appear twice.
231
+ // Detect UUIDs by length (36) + presence of dashes.
232
+ const refDesc =
233
+ row.kind === "pr"
234
+ ? row.refId.includes("#")
235
+ ? `PR ${row.refId}`
236
+ : `PR ${row.refRepo ?? ""} (${row.refId.slice(0, 8)}…)`
237
+ : `${row.kind} ${row.refId.slice(0, 8)}…`;
238
+ const text = `:speech_balloon: *New feedback* on ${refDesc} via _${row.source}_ from ${author}\n>${preview}${
239
+ link ? `\n<${link}|Open in admin>` : ""
240
+ }`;
241
+ try {
242
+ const res = await fetch(webhookUrl, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json" },
245
+ body: JSON.stringify({ text }),
246
+ });
247
+ if (!res.ok) {
248
+ log.warn({ status: res.status }, "feedback slack webhook returned non-2xx");
249
+ }
250
+ } catch (err) {
251
+ log.warn({ err }, "feedback slack webhook fetch failed");
252
+ }
253
+ }
254
+
255
+ // Used by github.ts to decide whether a PR comment counts as feedback.
256
+ // Greptile and other bot accounts have type='Bot'; their reviews are
257
+ // review-bot output, not user feedback. The author_association check
258
+ // keeps us from accepting drive-by comments from random GitHub users
259
+ // who happened to wander in — feedback should come from people who
260
+ // have a real relationship with the PR.
261
+ //
262
+ // Possible author_associations: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR,
263
+ // FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, MANNEQUIN, NONE.
264
+ const FEEDBACK_ASSOCIATIONS = new Set([
265
+ "OWNER",
266
+ "MEMBER",
267
+ "COLLABORATOR",
268
+ "CONTRIBUTOR",
269
+ "FIRST_TIME_CONTRIBUTOR",
270
+ ]);
271
+
272
+ export function isFeedbackEligibleCommenter(opts: {
273
+ userType?: string | null;
274
+ authorAssociation?: string | null;
275
+ }): boolean {
276
+ if (opts.userType === "Bot") return false;
277
+ if (!opts.authorAssociation) return false;
278
+ return FEEDBACK_ASSOCIATIONS.has(opts.authorAssociation);
279
+ }
280
+
281
+ // Used by the dropped FEEDBACK_SLACK_WEBHOOK notifier — exported so tests
282
+ // can confirm the env var is honored without invoking fetch.
283
+ export function feedbackSlackWebhookConfigured(): boolean {
284
+ return !!process.env.FEEDBACK_SLACK_WEBHOOK;
285
+ }
286
+
287
+ // Marker used by the github webhook to filter out our own footer so we
288
+ // don't surface our own link as feedback when GitHub echoes it back.
289
+ // The footer string itself is rendered by `renderFeedbackFooter` in
290
+ // apps/worker/src/github-app.ts (worker can't import from the api app),
291
+ // so any change to the URL path there needs to be mirrored here. The
292
+ // path `/feedback/pr/` is wired to a router pattern and is unlikely to
293
+ // move; matching on it (rather than the whole footer text) keeps this
294
+ // resilient to copy edits in the footer.
295
+ export const FEEDBACK_PR_FOOTER_MARKER = "/feedback/pr/";
296
+
297
+ // Used by the admin inbox query — also exported to keep the
298
+ // authorAssociation list in one place. Tests import this set directly.
299
+ export { FEEDBACK_ASSOCIATIONS };
superlog/apps/api/src/gateway.ts ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import {
3
+ db,
4
+ generateApiKey,
5
+ generateCliSession,
6
+ hashCliSession,
7
+ listAccessibleGithubInstallsForProject,
8
+ schema,
9
+ syncLoopsContactForUserProject,
10
+ } from "@superlog/db";
11
+ import { and, eq, isNull } from "drizzle-orm";
12
+ import type { Hono } from "hono";
13
+ import { nanoid } from "nanoid";
14
+
15
+ import { auth } from "./auth.js";
16
+ import { logger } from "./logger.js";
17
+ import { resolveActiveOrgContext } from "./org-context.js";
18
+
19
+ const log = logger.child({ scope: "gateway" });
20
+
21
+ const UPSTREAM = "https://api.anthropic.com";
22
+ const HARD_CAP_PER_ORG = Number(process.env.SUPERLOG_HARD_CAP ?? 500);
23
+ const DEVICE_TTL_MS = 10 * 60 * 1000;
24
+ const SESSION_TTL_DAYS = 60;
25
+
26
+ export type Principal = {
27
+ sessionId: string;
28
+ userId: string;
29
+ orgId: string;
30
+ userEmail: string;
31
+ orgName: string;
32
+ };
33
+
34
+ // "cli" — MCP / `superlog init` style pairing. Goes through a GitHub-install
35
+ // gate in the activate page before releasing the CLI token to the poller.
36
+ // "skill" — agent skill (e.g. superlog-onboard) running in the user's terminal
37
+ // pairing for OTel ingest only. Skips the GitHub gate (the post-signup
38
+ // onboarding wizard handles GH + Slack). The poller only receives an ingest
39
+ // key, never a CLI session token, since the skill never calls /v1/* gateway
40
+ // routes.
41
+ type DeviceFlow = "cli" | "skill";
42
+
43
+ type Device = {
44
+ deviceCode: string;
45
+ userCode: string;
46
+ flow: DeviceFlow;
47
+ createdAt: number;
48
+ // user_linked = sign-in done and account/key rows created. For "cli"
49
+ // flow we hold here until the GitHub install step finishes; for "skill" we
50
+ // transition straight to approved.
51
+ status: "pending" | "user_linked" | "approved" | "expired";
52
+ session?: {
53
+ cliToken: string;
54
+ ingestKey: string;
55
+ orgId: string;
56
+ projectId: string;
57
+ userEmail: string;
58
+ orgName: string;
59
+ };
60
+ };
61
+
62
+ const usageByOrg = new Map<string, number>();
63
+ const devicesByDeviceCode = new Map<string, Device>();
64
+ const devicesByUserCode = new Map<string, Device>();
65
+
66
+ export function getLinkedDevice(userCode: string): { orgId: string; projectId: string } | null {
67
+ const device = devicesByUserCode.get(userCode.toUpperCase());
68
+ if (!device || !device.session) return null;
69
+ if (isExpired(device)) return null;
70
+ if (device.status !== "user_linked") return null;
71
+ return { orgId: device.session.orgId, projectId: device.session.projectId };
72
+ }
73
+
74
+ // Used by the post-pair integration redirects (GitHub install, Slack install)
75
+ // initiated from the skill. The skill device is already in `approved` state
76
+ // by the time it tries to drive these — getLinkedDevice rejects that.
77
+ // Accepts either user_linked or approved, but only for skill devices, since
78
+ // the cli/MCP flow has its own approval semantics that shouldn't be reused
79
+ // here.
80
+ export function getSkillDeviceForIntegration(
81
+ userCode: string,
82
+ ): { orgId: string; projectId: string } | null {
83
+ const device = devicesByUserCode.get(userCode.toUpperCase());
84
+ if (!device || !device.session) return null;
85
+ if (device.flow !== "skill") return null;
86
+ if (isExpired(device)) return null;
87
+ if (device.status !== "user_linked" && device.status !== "approved") return null;
88
+ return { orgId: device.session.orgId, projectId: device.session.projectId };
89
+ }
90
+
91
+ export function getDeviceFlow(userCode: string): DeviceFlow | null {
92
+ const device = devicesByUserCode.get(userCode.toUpperCase());
93
+ if (!device) return null;
94
+ return device.flow;
95
+ }
96
+
97
+ export type GatewayVars = { principal: Principal };
98
+
99
+ // The host app's Variables type is invariant in Hono. We don't enforce it at
100
+ // the call site — callers should declare `principal?: Principal` in their own
101
+ // Vars if they want typed access downstream.
102
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
103
+ export function mountGateway(app: Hono<any>, ch: ClickHouseClient): void {
104
+ const upstreamKey = process.env.ANTHROPIC_API_KEY;
105
+ if (!upstreamKey) {
106
+ log.warn("ANTHROPIC_API_KEY not set — /v1/messages proxy disabled");
107
+ }
108
+ const publicUrl =
109
+ process.env.GATEWAY_PUBLIC_URL ?? `http://localhost:${process.env.PORT ?? 4100}`;
110
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
111
+
112
+ app.post("/oauth/device", async (c) => {
113
+ // Optional `flow` field — accept JSON body but tolerate empty/no body for
114
+ // older callers (the MCP CLI flow doesn't send one).
115
+ let flow: DeviceFlow = "cli";
116
+ const ct = c.req.header("content-type") ?? "";
117
+ if (ct.toLowerCase().includes("application/json")) {
118
+ const body = (await c.req.json().catch(() => ({}))) as { flow?: unknown };
119
+ if (body.flow === "skill") flow = "skill";
120
+ } else {
121
+ const form = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>);
122
+ if (form.flow === "skill") flow = "skill";
123
+ }
124
+
125
+ const deviceCode = `superlog_dev_${nanoid(24)}`;
126
+ const userCode = humanCode();
127
+ const device: Device = {
128
+ deviceCode,
129
+ userCode,
130
+ flow,
131
+ createdAt: Date.now(),
132
+ status: "pending",
133
+ };
134
+ devicesByDeviceCode.set(deviceCode, device);
135
+ devicesByUserCode.set(userCode, device);
136
+ const verificationUri = new URL(`${webOrigin}/activate`);
137
+ if (flow === "skill") verificationUri.searchParams.set("flow", "skill");
138
+ const verificationUriComplete = new URL(verificationUri.toString());
139
+ verificationUriComplete.searchParams.set("code", userCode);
140
+ return c.json({
141
+ device_code: deviceCode,
142
+ user_code: userCode,
143
+ verification_uri: verificationUri.toString(),
144
+ verification_uri_complete: verificationUriComplete.toString(),
145
+ expires_in: Math.floor(DEVICE_TTL_MS / 1000),
146
+ interval: 2,
147
+ });
148
+ });
149
+
150
+ app.post("/oauth/token", async (c, next) => {
151
+ // The MCP OAuth flow registers its own /oauth/token at this path with
152
+ // RFC 6749 form-encoded grants (authorization_code, refresh_token).
153
+ // CLI device-flow requests come in as JSON with a device_code field —
154
+ // anything else falls through to the MCP handler.
155
+ const ct = c.req.header("content-type") ?? "";
156
+ if (!ct.toLowerCase().includes("application/json")) {
157
+ return next();
158
+ }
159
+ const body = (await c.req.json().catch(() => ({}))) as { device_code?: string };
160
+ if (!body.device_code) return next();
161
+ const device = devicesByDeviceCode.get(body.device_code);
162
+ if (!device) return c.json({ error: "invalid_grant" }, 400);
163
+ if (isExpired(device)) {
164
+ device.status = "expired";
165
+ log.warn(
166
+ {
167
+ user_code: device.userCode,
168
+ flow: device.flow,
169
+ age_ms: Date.now() - device.createdAt,
170
+ },
171
+ "device flow token expired (oauth/token)",
172
+ );
173
+ return c.json({ error: "expired_token" }, 410);
174
+ }
175
+ if (device.status !== "approved" || !device.session) {
176
+ return c.json({ error: "authorization_pending" }, 428);
177
+ }
178
+ if (device.flow === "skill") {
179
+ // Skill pollers only need the ingest key for OTel exporters; never
180
+ // expose the gateway-scope CLI session token.
181
+ // Refresh the device's createdAt so the post-pair integration URLs
182
+ // (`/github/install?user_code=…`, `/slack/install?user_code=…`) the
183
+ // skill is about to drive don't tip over the 10-min device TTL while
184
+ // the user clicks through GitHub + Slack OAuth.
185
+ device.createdAt = Date.now();
186
+ return c.json({
187
+ ingest_key: device.session.ingestKey,
188
+ project_id: device.session.projectId,
189
+ user: device.session.userEmail,
190
+ org: device.session.orgName,
191
+ user_code: device.userCode,
192
+ flow: "skill",
193
+ });
194
+ }
195
+ return c.json({
196
+ access_token: device.session.cliToken,
197
+ token_type: "Bearer",
198
+ ingest_key: device.session.ingestKey,
199
+ project_id: device.session.projectId,
200
+ user: device.session.userEmail,
201
+ org: device.session.orgName,
202
+ gateway_url: publicUrl,
203
+ });
204
+ });
205
+
206
+ app.get("/activate", (c) => {
207
+ const code = c.req.query("code") ?? "";
208
+ const url = new URL(`${webOrigin}/activate`);
209
+ if (code) url.searchParams.set("code", code);
210
+ return c.redirect(url.toString(), 302);
211
+ });
212
+
213
+ app.post("/activate/approve", async (c) => {
214
+ const userId = await requireUserFromSession(c);
215
+ if (userId instanceof Response) return userId;
216
+
217
+ const body = (await c.req.json().catch(() => ({}))) as {
218
+ user_code?: string;
219
+ org_id?: string;
220
+ project_id?: string;
221
+ };
222
+ const userCode = body.user_code?.toUpperCase() ?? "";
223
+ const device = devicesByUserCode.get(userCode);
224
+ if (!device) return c.json({ error: "unknown device code" }, 404);
225
+ if (isExpired(device)) {
226
+ device.status = "expired";
227
+ log.warn(
228
+ {
229
+ user_code: userCode,
230
+ user_id: userId,
231
+ flow: device.flow,
232
+ age_ms: Date.now() - device.createdAt,
233
+ },
234
+ "device code expired (activate/approve)",
235
+ );
236
+ return c.json({ error: "device code expired" }, 410);
237
+ }
238
+ if (device.status === "approved" || device.status === "user_linked") {
239
+ return c.json({ error: "already approved" }, 409);
240
+ }
241
+
242
+ const { user, org, project } = await ensureAccount(userId, {
243
+ orgId: body.org_id ?? null,
244
+ projectId: body.project_id ?? null,
245
+ });
246
+
247
+ const cli = generateCliSession();
248
+ const ingest = generateApiKey();
249
+ const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
250
+
251
+ await db.transaction(async (tx) => {
252
+ await tx.insert(schema.cliSessions).values({
253
+ userId: user.id,
254
+ orgId: org.id,
255
+ tokenPrefix: cli.prefix,
256
+ tokenHash: cli.hash,
257
+ expiresAt,
258
+ });
259
+ await tx.insert(schema.apiKeys).values({
260
+ projectId: project.id,
261
+ name: "CLI-issued ingest key",
262
+ keyPrefix: ingest.prefix,
263
+ keyHash: ingest.hash,
264
+ });
265
+ });
266
+
267
+ device.status = "user_linked";
268
+ device.session = {
269
+ cliToken: cli.plaintext,
270
+ ingestKey: ingest.plaintext,
271
+ orgId: org.id,
272
+ projectId: project.id,
273
+ userEmail: user.email,
274
+ orgName: org.name,
275
+ };
276
+ void syncLoopsContactForUserProject({
277
+ userId: user.id,
278
+ projectId: project.id,
279
+ appUrl: webOrigin,
280
+ }).catch((err) =>
281
+ log.warn({ err, user_id: user.id }, "loops contact sync failed after activation"),
282
+ );
283
+
284
+ // Skill flow has no GitHub gate — the post-signup OnboardingWizard prompts
285
+ // for GitHub + Slack once the user lands on the dashboard. Approve in one
286
+ // hop so the skill's poller can pick up the ingest key immediately.
287
+ if (device.flow === "skill") {
288
+ device.status = "approved";
289
+ return c.json({
290
+ ok: true,
291
+ orgId: org.id,
292
+ projectId: project.id,
293
+ flow: "skill",
294
+ ingestKey: ingest.plaintext,
295
+ githubSetupNeeded: false,
296
+ });
297
+ }
298
+
299
+ const accessibleInstalls = await listAccessibleGithubInstallsForProject(project.id);
300
+ const githubSetupNeeded = accessibleInstalls.length === 0 && !org.githubSetupSkippedAt;
301
+
302
+ return c.json({ ok: true, orgId: org.id, flow: "cli", githubSetupNeeded });
303
+ });
304
+
305
+ app.post("/activate/finalize", async (c) => {
306
+ const userId = await requireUserFromSession(c);
307
+ if (userId instanceof Response) return userId;
308
+ void userId;
309
+
310
+ const body = (await c.req.json().catch(() => ({}))) as { user_code?: string };
311
+ const userCode = body.user_code?.toUpperCase() ?? "";
312
+ const device = devicesByUserCode.get(userCode);
313
+ if (!device) return c.json({ error: "unknown device code" }, 404);
314
+ if (isExpired(device)) {
315
+ device.status = "expired";
316
+ log.warn(
317
+ {
318
+ user_code: userCode,
319
+ flow: device.flow,
320
+ age_ms: Date.now() - device.createdAt,
321
+ },
322
+ "device code expired (activate/finalize)",
323
+ );
324
+ return c.json({ error: "device code expired" }, 410);
325
+ }
326
+ if (device.status === "approved") return c.json({ ok: true });
327
+ if (device.status !== "user_linked" || !device.session) {
328
+ return c.json({ error: "not ready to finalize" }, 409);
329
+ }
330
+
331
+ device.status = "approved";
332
+ return c.json({ ok: true });
333
+ });
334
+
335
+ app.use("/v1/*", async (c, next) => {
336
+ const header = c.req.header("authorization");
337
+ if (!header?.toLowerCase().startsWith("bearer ")) {
338
+ return c.json({ error: "unauthenticated" }, 401);
339
+ }
340
+ const token = header.slice(7).trim();
341
+ if (!token.startsWith("superlog_cli_")) {
342
+ return c.json(
343
+ {
344
+ error:
345
+ "wrong credential type: /v1/* requires a CLI session token (superlog_cli_*); did you mean to call intake.superlog.sh with your ingest key?",
346
+ },
347
+ 401,
348
+ );
349
+ }
350
+ const hash = hashCliSession(token);
351
+ const session = await db.query.cliSessions.findFirst({
352
+ where: eq(schema.cliSessions.tokenHash, hash),
353
+ });
354
+ if (!session || session.revokedAt) return c.json({ error: "invalid token" }, 401);
355
+ if (session.expiresAt && session.expiresAt < new Date()) {
356
+ return c.json({ error: "session expired" }, 401);
357
+ }
358
+
359
+ const user = await db.query.users.findFirst({
360
+ where: eq(schema.users.id, session.userId),
361
+ });
362
+ const org = await db.query.orgs.findFirst({
363
+ where: eq(schema.orgs.id, session.orgId),
364
+ });
365
+ if (!user || !org) return c.json({ error: "session references missing account" }, 401);
366
+
367
+ c.set("principal", {
368
+ sessionId: session.id,
369
+ userId: session.userId,
370
+ orgId: session.orgId,
371
+ userEmail: user.email,
372
+ orgName: org.name,
373
+ });
374
+
375
+ void db
376
+ .update(schema.cliSessions)
377
+ .set({ lastUsedAt: new Date() })
378
+ .where(eq(schema.cliSessions.id, session.id))
379
+ .catch((err: unknown) => log.error({ err }, "failed to bump last_used_at"));
380
+
381
+ await next();
382
+ });
383
+
384
+ app.get("/v1/me", (c) => {
385
+ const p = c.var.principal as Principal;
386
+ return c.json({
387
+ user: p.userEmail,
388
+ org: p.orgName,
389
+ usage: usageByOrg.get(p.orgId) ?? 0,
390
+ cap: HARD_CAP_PER_ORG,
391
+ });
392
+ });
393
+
394
+ app.all("/v1/messages", async (c) => {
395
+ if (!upstreamKey) {
396
+ return c.json({ error: "gateway upstream not configured" }, 503);
397
+ }
398
+ const p = c.var.principal as Principal;
399
+ const used = usageByOrg.get(p.orgId) ?? 0;
400
+ if (used >= HARD_CAP_PER_ORG) {
401
+ return c.json(
402
+ {
403
+ error: {
404
+ type: "rate_limit_error",
405
+ message: `org cap reached (${HARD_CAP_PER_ORG})`,
406
+ },
407
+ },
408
+ 429,
409
+ );
410
+ }
411
+ usageByOrg.set(p.orgId, used + 1);
412
+ log.info(
413
+ { user: p.userEmail, org: p.orgName, used: used + 1, cap: HARD_CAP_PER_ORG },
414
+ "/v1/messages",
415
+ );
416
+ return proxyToAnthropic(c.req.raw, "/v1/messages", upstreamKey);
417
+ });
418
+
419
+ app.get("/v1/telemetry/recent", async (c) => {
420
+ const p = c.var.principal as Principal;
421
+ const service = c.req.query("service");
422
+ const since = c.req.query("since");
423
+ if (!service || !since) {
424
+ return c.json({ error: "service and since query params required" }, 400);
425
+ }
426
+
427
+ // The install-verify step only cares about the signed-in user's default
428
+ // project. Multi-project users would query by project_id explicitly; that
429
+ // case can wait until we actually have it.
430
+ const project = await db.query.projects.findFirst({
431
+ where: eq(schema.projects.orgId, p.orgId),
432
+ });
433
+ if (!project) return c.json({ error: "no project for this org" }, 404);
434
+
435
+ const [traces, logs, metrics] = await Promise.all([
436
+ chCountTraces(ch, project.id, service, since),
437
+ chCountLogs(ch, project.id, service, since),
438
+ chMetricsCountAcrossTables(ch, project.id, service, since),
439
+ ]);
440
+
441
+ return c.json({
442
+ service,
443
+ since,
444
+ projectId: project.id,
445
+ traces,
446
+ logs,
447
+ metrics,
448
+ });
449
+ });
450
+
451
+ app.all("/v1/*", (c) => {
452
+ if (!upstreamKey) {
453
+ return c.json({ error: "gateway upstream not configured" }, 503);
454
+ }
455
+ return proxyToAnthropic(c.req.raw, new URL(c.req.url).pathname, upstreamKey);
456
+ });
457
+ }
458
+
459
+ async function chCountTraces(
460
+ ch: ClickHouseClient,
461
+ projectId: string,
462
+ service: string,
463
+ since: string,
464
+ ): Promise<{ count: number; firstAt?: string; firstSpanName?: string }> {
465
+ const r = await ch.query({
466
+ query: `
467
+ SELECT count() AS c,
468
+ toString(min(Timestamp)) AS first_at,
469
+ argMin(SpanName, Timestamp) AS first_span
470
+ FROM otel_traces
471
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
472
+ AND ServiceName = {service:String}
473
+ AND Timestamp >= parseDateTime64BestEffortOrZero({since:String})
474
+ `,
475
+ query_params: { projectId, service, since },
476
+ format: "JSONEachRow",
477
+ });
478
+ const rows = (await r.json()) as { c: string | number; first_at?: string; first_span?: string }[];
479
+ const row = rows[0];
480
+ const count = Number(row?.c ?? 0);
481
+ return {
482
+ count,
483
+ firstAt: count > 0 ? row?.first_at : undefined,
484
+ firstSpanName: count > 0 ? row?.first_span : undefined,
485
+ };
486
+ }
487
+
488
+ async function chCountLogs(
489
+ ch: ClickHouseClient,
490
+ projectId: string,
491
+ service: string,
492
+ since: string,
493
+ ): Promise<{ count: number }> {
494
+ const r = await ch.query({
495
+ query: `
496
+ SELECT count() AS c
497
+ FROM otel_logs
498
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
499
+ AND ServiceName = {service:String}
500
+ AND Timestamp >= parseDateTime64BestEffortOrZero({since:String})
501
+ `,
502
+ query_params: { projectId, service, since },
503
+ format: "JSONEachRow",
504
+ });
505
+ const rows = (await r.json()) as { c: string | number }[];
506
+ return { count: Number(rows[0]?.c ?? 0) };
507
+ }
508
+
509
+ async function chMetricsCountAcrossTables(
510
+ ch: ClickHouseClient,
511
+ projectId: string,
512
+ service: string,
513
+ since: string,
514
+ ): Promise<{ count: number }> {
515
+ const tables = [
516
+ "otel_metrics_gauge",
517
+ "otel_metrics_sum",
518
+ "otel_metrics_histogram",
519
+ "otel_metrics_summary",
520
+ "otel_metrics_exp_histogram",
521
+ ];
522
+ const counts = await Promise.all(
523
+ tables.map(async (t) => {
524
+ const r = await ch.query({
525
+ query: `
526
+ SELECT count() AS c
527
+ FROM ${t}
528
+ WHERE ResourceAttributes['superlog.project_id'] = {projectId:String}
529
+ AND ServiceName = {service:String}
530
+ AND TimeUnix >= parseDateTime64BestEffortOrZero({since:String})
531
+ `,
532
+ query_params: { projectId, service, since },
533
+ format: "JSONEachRow",
534
+ });
535
+ const rows = (await r.json()) as { c: string | number }[];
536
+ return Number(rows[0]?.c ?? 0);
537
+ }),
538
+ );
539
+ return { count: counts.reduce((a, b) => a + b, 0) };
540
+ }
541
+
542
+ async function ensureAccount(
543
+ userId: string,
544
+ scope: { orgId: string | null; projectId: string | null } = {
545
+ orgId: null,
546
+ projectId: null,
547
+ },
548
+ ): Promise<{ user: schema.User; org: schema.Org; project: schema.Project }> {
549
+ const { user, org, project } = await resolveActiveOrgContext({
550
+ userId,
551
+ preferredOrgId: scope.orgId,
552
+ preferredProjectId: scope.projectId,
553
+ });
554
+ return { user, org, project };
555
+ }
556
+
557
+ export async function proxyToAnthropic(
558
+ req: Request,
559
+ path: string,
560
+ upstreamKey: string,
561
+ ): Promise<Response> {
562
+ const url = new URL(path, UPSTREAM);
563
+ // Allowlist only headers Anthropic needs. Forwarding cf-* / x-forwarded-*
564
+ // from our inbound (api.superlog.sh is orange-clouded) makes Anthropic's
565
+ // own Cloudflare edge think the request has already traversed CF and
566
+ // reject it with Error 1000 ("DNS points to prohibited IP").
567
+ const FORWARD = new Set(["content-type", "accept", "accept-encoding"]);
568
+ const headers = new Headers();
569
+ for (const [name, value] of req.headers) {
570
+ const lower = name.toLowerCase();
571
+ if (FORWARD.has(lower) || lower.startsWith("anthropic-") || lower.startsWith("x-stainless-")) {
572
+ headers.set(name, value);
573
+ }
574
+ }
575
+ headers.set("x-api-key", upstreamKey);
576
+ if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
577
+
578
+ const upstream = await fetch(url, {
579
+ method: req.method,
580
+ headers,
581
+ body:
582
+ req.method === "GET" || req.method === "HEAD"
583
+ ? undefined
584
+ : (req.body as ReadableStream | null),
585
+ duplex: "half",
586
+ redirect: "manual",
587
+ } as RequestInit & { duplex: "half" });
588
+
589
+ const respHeaders = new Headers(upstream.headers);
590
+ respHeaders.delete("content-encoding");
591
+ respHeaders.delete("content-length");
592
+ return new Response(upstream.body, { status: upstream.status, headers: respHeaders });
593
+ }
594
+
595
+ async function requireUserFromSession(
596
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Context generics vary across mount points.
597
+ c: any,
598
+ ): Promise<string | Response> {
599
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
600
+ if (!session) return c.json({ error: "unauthenticated" }, 401);
601
+ return session.user.id;
602
+ }
603
+
604
+ function humanCode(): string {
605
+ const alphabet = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
606
+ const pick = () => alphabet[Math.floor(Math.random() * alphabet.length)];
607
+ return `${pick()}${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}${pick()}`;
608
+ }
609
+
610
+ function isExpired(d: Device): boolean {
611
+ return Date.now() - d.createdAt > DEVICE_TTL_MS;
612
+ }
superlog/apps/api/src/github-branches.test.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { mergeRepoBranches, prBaseBranchExists } from "./github-branches.js";
4
+
5
+ test("mergeRepoBranches dedupes across repos, marks defaults, and sorts defaults first", () => {
6
+ const merged = mergeRepoBranches([
7
+ { defaultBranch: "main", branches: ["main", "dev", "feature/x"] },
8
+ { defaultBranch: "master", branches: ["master", "dev"] },
9
+ ]);
10
+ assert.deepEqual(merged, [
11
+ { name: "main", isDefault: true },
12
+ { name: "master", isDefault: true },
13
+ { name: "dev", isDefault: false },
14
+ { name: "feature/x", isDefault: false },
15
+ ]);
16
+ });
17
+
18
+ test("mergeRepoBranches treats a branch as default if it is the default in any repo", () => {
19
+ const merged = mergeRepoBranches([
20
+ { defaultBranch: null, branches: ["dev"] },
21
+ { defaultBranch: "dev", branches: ["dev"] },
22
+ ]);
23
+ assert.deepEqual(merged, [{ name: "dev", isDefault: true }]);
24
+ });
25
+
26
+ test("mergeRepoBranches includes the default branch even if absent from the branch list", () => {
27
+ const merged = mergeRepoBranches([{ defaultBranch: "main", branches: [] }]);
28
+ assert.deepEqual(merged, [{ name: "main", isDefault: true }]);
29
+ });
30
+
31
+ test("mergeRepoBranches ignores blank names and trims", () => {
32
+ const merged = mergeRepoBranches([{ defaultBranch: " ", branches: [" ", " dev "] }]);
33
+ assert.deepEqual(merged, [{ name: "dev", isDefault: false }]);
34
+ });
35
+
36
+ test("prBaseBranchExists allows blank (uses repository default)", () => {
37
+ const branches = [{ name: "main", isDefault: true }];
38
+ assert.equal(prBaseBranchExists(null, branches), true);
39
+ assert.equal(prBaseBranchExists("", branches), true);
40
+ assert.equal(prBaseBranchExists(" ", branches), true);
41
+ });
42
+
43
+ test("prBaseBranchExists requires a non-blank branch to be present", () => {
44
+ const branches = [
45
+ { name: "main", isDefault: true },
46
+ { name: "dev", isDefault: false },
47
+ ];
48
+ assert.equal(prBaseBranchExists("dev", branches), true);
49
+ assert.equal(prBaseBranchExists(" dev ", branches), true);
50
+ assert.equal(prBaseBranchExists("nope", branches), false);
51
+ });
superlog/apps/api/src/github-branches.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { normalizePrBaseBranch } from "@superlog/db/schema";
2
+
3
+ export type RepoBranch = { name: string; isDefault: boolean };
4
+
5
+ export type RepoBranchInfo = { defaultBranch: string | null; branches: string[] };
6
+
7
+ // Pure: collapse the per-repo branch lists of a project into one deduped,
8
+ // sorted set for the config dropdown. A name is `isDefault` if it's the
9
+ // default branch of ANY repo. The default branch is always included even if
10
+ // the paginated branch list didn't surface it. Defaults sort first, then
11
+ // alphabetical — so the most likely target is at the top of the picker.
12
+ export function mergeRepoBranches(perRepo: RepoBranchInfo[]): RepoBranch[] {
13
+ const names = new Set<string>();
14
+ const defaults = new Set<string>();
15
+ for (const repo of perRepo) {
16
+ for (const raw of repo.branches) {
17
+ const name = raw.trim();
18
+ if (name) names.add(name);
19
+ }
20
+ const def = repo.defaultBranch?.trim();
21
+ if (def) {
22
+ names.add(def);
23
+ defaults.add(def);
24
+ }
25
+ }
26
+ return [...names]
27
+ .map((name) => ({ name, isDefault: defaults.has(name) }))
28
+ .sort((a, b) => {
29
+ if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1;
30
+ return a.name.localeCompare(b.name);
31
+ });
32
+ }
33
+
34
+ // Pure: a configured PR base branch is valid if it's blank (meaning "use the
35
+ // repository default") or it matches a branch that actually exists in one of
36
+ // the project's repos.
37
+ export function prBaseBranchExists(
38
+ candidate: string | null | undefined,
39
+ branches: RepoBranch[],
40
+ ): boolean {
41
+ const normalized = normalizePrBaseBranch(candidate);
42
+ if (!normalized) return true;
43
+ return branches.some((branch) => branch.name === normalized);
44
+ }
superlog/apps/api/src/github.test.ts ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import "dotenv/config";
2
+ import { strict as assert } from "node:assert";
3
+ import crypto from "node:crypto";
4
+ import { after, before, test } from "node:test";
5
+ import { closeDb, db, runMigrations, schema } from "@superlog/db";
6
+ import { and, eq } from "drizzle-orm";
7
+ import { Hono } from "hono";
8
+ import { mountGithubPublic } from "./github.js";
9
+
10
+ const GH_WEBHOOK_SECRET = "github-test-secret";
11
+ process.env.GITHUB_APP_WEBHOOK_SECRET = GH_WEBHOOK_SECRET;
12
+
13
+ const orgIds: string[] = [];
14
+
15
+ before(async () => {
16
+ await runMigrations();
17
+ });
18
+
19
+ after(async () => {
20
+ try {
21
+ for (const orgId of orgIds.reverse()) {
22
+ await db.delete(schema.orgs).where(eq(schema.orgs.id, orgId));
23
+ }
24
+ } finally {
25
+ await closeDb();
26
+ }
27
+ });
28
+
29
+ test("merged agent PR resolves incident, cascades linked issues, and writes timeline events", async () => {
30
+ const fixture = await seedAgentPrFixture("merged");
31
+ const app = new Hono();
32
+ mountGithubPublic(app);
33
+
34
+ const mergedAt = new Date().toISOString();
35
+ const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, {
36
+ action: "closed",
37
+ repository: { full_name: fixture.repoFullName },
38
+ pull_request: {
39
+ number: fixture.prNumber,
40
+ merged: true,
41
+ merged_at: mergedAt,
42
+ closed_at: mergedAt,
43
+ merged_by: { login: "alice", id: 100 },
44
+ user: { login: "superlog-bot", id: 999 },
45
+ head: { sha: "cafebabe", ref: fixture.branchName },
46
+ },
47
+ sender: { login: "alice", id: 100 },
48
+ installation: { id: fixture.installationId },
49
+ });
50
+
51
+ assert.equal(res.status, 200);
52
+
53
+ const pr = await db.query.agentPullRequests.findFirst({
54
+ where: eq(schema.agentPullRequests.id, fixture.agentPrId),
55
+ });
56
+ assert.equal(pr?.state, "merged");
57
+ assert.equal(pr?.mergedByLogin, "alice");
58
+ assert.ok(pr?.mergedAt);
59
+
60
+ const incident = await db.query.incidents.findFirst({
61
+ where: eq(schema.incidents.id, fixture.incidentId),
62
+ });
63
+ assert.equal(incident?.status, "resolved");
64
+
65
+ const issue = await db.query.issues.findFirst({
66
+ where: eq(schema.issues.id, fixture.issueId),
67
+ });
68
+ assert.ok(issue);
69
+
70
+ const resolvedEvents = await db.query.incidentEvents.findMany({
71
+ where: and(
72
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
73
+ eq(schema.incidentEvents.kind, "incident_resolved"),
74
+ ),
75
+ });
76
+ assert.equal(resolvedEvents.length, 1);
77
+ assert.equal(
78
+ resolvedEvents[0]?.summary,
79
+ `Incident resolved because PR #${fixture.prNumber} was merged.`,
80
+ );
81
+ assert.equal(resolvedEvents[0]?.detail?.reason, "agent_pr_merged");
82
+
83
+ const prMergedEvent = await db.query.agentPrEvents.findFirst({
84
+ where: and(
85
+ eq(schema.agentPrEvents.agentPrId, fixture.agentPrId),
86
+ eq(schema.agentPrEvents.kind, "pr_merged"),
87
+ ),
88
+ });
89
+ assert.ok(prMergedEvent);
90
+
91
+ const duplicate = await postGithub(app, "pull_request", `gh-${fixture.tag}-merged`, {
92
+ action: "closed",
93
+ repository: { full_name: fixture.repoFullName },
94
+ pull_request: {
95
+ number: fixture.prNumber,
96
+ merged: true,
97
+ merged_at: mergedAt,
98
+ closed_at: mergedAt,
99
+ merged_by: { login: "alice", id: 100 },
100
+ },
101
+ sender: { login: "alice", id: 100 },
102
+ installation: { id: fixture.installationId },
103
+ });
104
+ assert.equal(duplicate.status, 200);
105
+
106
+ const resolvedEventsAfterDuplicate = await db.query.incidentEvents.findMany({
107
+ where: and(
108
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
109
+ eq(schema.incidentEvents.kind, "incident_resolved"),
110
+ ),
111
+ });
112
+ assert.equal(resolvedEventsAfterDuplicate.length, 1);
113
+ });
114
+
115
+ test("closed unmerged agent PR does not resolve incident or linked issue", async () => {
116
+ const fixture = await seedAgentPrFixture("closed");
117
+ const app = new Hono();
118
+ mountGithubPublic(app);
119
+
120
+ const closedAt = new Date().toISOString();
121
+ const res = await postGithub(app, "pull_request", `gh-${fixture.tag}-closed`, {
122
+ action: "closed",
123
+ repository: { full_name: fixture.repoFullName },
124
+ pull_request: {
125
+ number: fixture.prNumber,
126
+ merged: false,
127
+ closed_at: closedAt,
128
+ user: { login: "superlog-bot", id: 999 },
129
+ },
130
+ sender: { login: "alice", id: 100 },
131
+ installation: { id: fixture.installationId },
132
+ });
133
+
134
+ assert.equal(res.status, 200);
135
+
136
+ const pr = await db.query.agentPullRequests.findFirst({
137
+ where: eq(schema.agentPullRequests.id, fixture.agentPrId),
138
+ });
139
+ assert.equal(pr?.state, "closed");
140
+
141
+ const incident = await db.query.incidents.findFirst({
142
+ where: eq(schema.incidents.id, fixture.incidentId),
143
+ });
144
+ assert.equal(incident?.status, "open");
145
+
146
+ const issue = await db.query.issues.findFirst({
147
+ where: eq(schema.issues.id, fixture.issueId),
148
+ });
149
+ assert.ok(issue);
150
+
151
+ const resolvedEvent = await db.query.incidentEvents.findFirst({
152
+ where: and(
153
+ eq(schema.incidentEvents.agentRunId, fixture.agentRunId),
154
+ eq(schema.incidentEvents.kind, "incident_resolved"),
155
+ ),
156
+ });
157
+ assert.equal(resolvedEvent, undefined);
158
+ });
159
+
160
+ async function seedAgentPrFixture(label: string): Promise<{
161
+ tag: string;
162
+ repoFullName: string;
163
+ branchName: string;
164
+ installationId: number;
165
+ incidentId: string;
166
+ issueId: string;
167
+ agentRunId: string;
168
+ agentPrId: string;
169
+ prNumber: number;
170
+ }> {
171
+ const tag = `test-${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
172
+ const repoFullName = `acme/${tag}`;
173
+ const branchName = `superlog/${tag}`;
174
+ const installationId = Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1_000_000);
175
+ const prNumber = Math.floor(Math.random() * 10_000) + 1;
176
+
177
+ const [org] = await db
178
+ .insert(schema.orgs)
179
+ .values({ name: tag, slug: tag })
180
+ .returning();
181
+ if (!org) throw new Error("failed to seed org");
182
+ orgIds.push(org.id);
183
+
184
+ const [project] = await db
185
+ .insert(schema.projects)
186
+ .values({ orgId: org.id, name: "test", slug: tag })
187
+ .returning();
188
+ if (!project) throw new Error("failed to seed project");
189
+
190
+ const now = new Date();
191
+ const [incident] = await db
192
+ .insert(schema.incidents)
193
+ .values({
194
+ projectId: project.id,
195
+ title: "Test incident",
196
+ service: "api",
197
+ firstSeen: now,
198
+ lastSeen: now,
199
+ })
200
+ .returning();
201
+ if (!incident) throw new Error("failed to seed incident");
202
+
203
+ const [issue] = await db
204
+ .insert(schema.issues)
205
+ .values({
206
+ projectId: project.id,
207
+ fingerprint: `fp-${tag}`,
208
+ kind: "span",
209
+ service: "api",
210
+ exceptionType: "Error",
211
+ title: "Test issue",
212
+ message: "boom",
213
+ firstSeen: now,
214
+ lastSeen: now,
215
+ })
216
+ .returning();
217
+ if (!issue) throw new Error("failed to seed issue");
218
+
219
+ await db.insert(schema.incidentIssues).values({
220
+ incidentId: incident.id,
221
+ issueId: issue.id,
222
+ });
223
+
224
+ const [agentRun] = await db
225
+ .insert(schema.agentRuns)
226
+ .values({ incidentId: incident.id, runtime: "anthropic", state: "running" })
227
+ .returning();
228
+ if (!agentRun) throw new Error("failed to seed agent run");
229
+
230
+ const [installation] = await db
231
+ .insert(schema.githubInstallations)
232
+ .values({
233
+ orgId: org.id,
234
+ projectId: project.id,
235
+ installationId,
236
+ accountLogin: "acme-bot",
237
+ accountType: "Organization",
238
+ repos: [{ id: 1, fullName: repoFullName, private: false }],
239
+ })
240
+ .returning();
241
+ if (!installation) throw new Error("failed to seed GitHub installation");
242
+
243
+ const [agentPr] = await db
244
+ .insert(schema.agentPullRequests)
245
+ .values({
246
+ incidentId: incident.id,
247
+ agentRunId: agentRun.id,
248
+ installationId: installation.id,
249
+ repoFullName,
250
+ prNumber,
251
+ prNodeId: `PR_${tag}`,
252
+ url: `https://github.com/${repoFullName}/pull/${prNumber}`,
253
+ branchName,
254
+ baseBranch: "main",
255
+ headSha: "deadbeef",
256
+ title: "[superlog] Fix bug",
257
+ state: "open",
258
+ lastSyncedAt: now,
259
+ })
260
+ .returning();
261
+ if (!agentPr) throw new Error("failed to seed agent PR");
262
+
263
+ return {
264
+ tag,
265
+ repoFullName,
266
+ branchName,
267
+ installationId,
268
+ incidentId: incident.id,
269
+ issueId: issue.id,
270
+ agentRunId: agentRun.id,
271
+ agentPrId: agentPr.id,
272
+ prNumber,
273
+ };
274
+ }
275
+
276
+ async function postGithub(
277
+ app: Hono,
278
+ event: string,
279
+ delivery: string,
280
+ payload: unknown,
281
+ ): Promise<Response> {
282
+ const body = JSON.stringify(payload);
283
+ return app.request("/github/webhook", {
284
+ method: "POST",
285
+ headers: {
286
+ "content-type": "application/json",
287
+ "x-github-event": event,
288
+ "x-github-delivery": delivery,
289
+ "x-hub-signature-256": ghSign(body),
290
+ },
291
+ body,
292
+ });
293
+ }
294
+
295
+ function ghSign(body: string): string {
296
+ return `sha256=${crypto.createHmac("sha256", GH_WEBHOOK_SECRET).update(body).digest("hex")}`;
297
+ }
superlog/apps/api/src/github.ts ADDED
@@ -0,0 +1,2037 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from "node:crypto";
2
+ import {
3
+ db,
4
+ listAccessibleGithubInstallsForProject,
5
+ resolveIncident,
6
+ schema,
7
+ syncLoopsContactsForOrg,
8
+ } from "@superlog/db";
9
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
10
+ import type { Hono } from "hono";
11
+ import type { Context } from "hono";
12
+ import {
13
+ FEEDBACK_PR_FOOTER_MARKER,
14
+ isFeedbackEligibleCommenter,
15
+ recordFeedback,
16
+ } from "./feedback.js";
17
+ import { getDeviceFlow, getLinkedDevice, getSkillDeviceForIntegration } from "./gateway.js";
18
+ import { type RepoBranch, type RepoBranchInfo, mergeRepoBranches } from "./github-branches.js";
19
+ import { runResolvedIncidentSideEffectsForIncident } from "./incidents/resolution-side-effects.js";
20
+ import { logger } from "./logger.js";
21
+ import { resolveActiveOrgContext } from "./org-context.js";
22
+
23
+ const log = logger.child({ scope: "github" });
24
+ type Vars = { userId: string; orgId: string | null };
25
+ const DEFAULT_COMMIT_AUTHOR = {
26
+ name: "Superlog app",
27
+ email: "bot@superlog.sh",
28
+ };
29
+
30
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
31
+ export function mountGithubPublic(app: Hono<any>): void {
32
+ const appSlug = process.env.GITHUB_APP_SLUG;
33
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
34
+ const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
35
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
36
+ const oauthClientId =
37
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
38
+ process.env.GITHUB_APP_CLIENT_ID ??
39
+ process.env.GITHUB_CLIENT_ID;
40
+ const oauthClientSecret =
41
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
42
+ process.env.GITHUB_APP_CLIENT_SECRET ??
43
+ process.env.GITHUB_CLIENT_SECRET;
44
+ const installRedirectUrl =
45
+ process.env.GITHUB_INSTALL_OAUTH_REDIRECT_URL ??
46
+ "http://localhost:4100/github/install/callback";
47
+
48
+ if (!appSlug) {
49
+ log.warn("GITHUB_APP_SLUG not set — /github/install disabled");
50
+ }
51
+ if (!stateSecret) {
52
+ log.warn("STATE_SIGNING_SECRET not set — /github/install disabled");
53
+ }
54
+ if (!webhookSecret) {
55
+ log.warn("GITHUB_APP_WEBHOOK_SECRET not set — /github/webhook disabled");
56
+ }
57
+
58
+ app.get("/github/install", (c) => {
59
+ if (!appSlug || !stateSecret) {
60
+ return c.json({ error: "github app not configured" }, 503);
61
+ }
62
+ const userCode = (c.req.query("user_code") ?? "").toUpperCase();
63
+ // CLI/MCP flow: device must be in user_linked state. Skill flow drives
64
+ // GitHub install AFTER pairing (device already approved), so fall back
65
+ // to the skill-device helper when getLinkedDevice rejects it.
66
+ const device = getLinkedDevice(userCode) ?? getSkillDeviceForIntegration(userCode);
67
+ if (!device) return c.json({ error: "unknown or not-ready device code" }, 404);
68
+
69
+ const state = signState("cli", userCode, stateSecret);
70
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
71
+ url.searchParams.set("state", state);
72
+ return c.redirect(url.toString(), 302);
73
+ });
74
+
75
+ app.get("/github/install/callback", async (c) => {
76
+ if (!stateSecret) return c.json({ error: "github app not configured" }, 503);
77
+ const installationIdRaw = c.req.query("installation_id");
78
+ const state = c.req.query("state") ?? "";
79
+ const setupAction = c.req.query("setup_action") ?? "install";
80
+ const oauthCode = c.req.query("code") ?? null;
81
+ log.info(
82
+ {
83
+ setup_action: setupAction,
84
+ installation_id: installationIdRaw ?? null,
85
+ has_oauth_code: !!oauthCode,
86
+ },
87
+ "github install callback received",
88
+ );
89
+
90
+ const installationId = Number(installationIdRaw);
91
+ const marker = setupAction === "update" ? "updated" : "done";
92
+
93
+ // First try the management-API state shape for platform provisioning.
94
+ // Falls through to the existing cli/web state shape if not a mgmt state.
95
+ const mgmt = verifyMgmtState(state, stateSecret);
96
+ if (mgmt) {
97
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
98
+ const failTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`;
99
+ if (!Number.isFinite(installationId) || installationId <= 0) {
100
+ return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302);
101
+ }
102
+ let resolvedOrgId: string;
103
+ let resolvedProjectId: string | null;
104
+ if (mgmt.scope === "org") {
105
+ resolvedOrgId = mgmt.orgId;
106
+ resolvedProjectId = null;
107
+ } else {
108
+ const project = await db.query.projects.findFirst({
109
+ where: eq(schema.projects.id, mgmt.projectId),
110
+ });
111
+ if (!project) {
112
+ return c.redirect(`${failTarget}${failTarget.includes("?") ? "&" : "?"}gh=error`, 302);
113
+ }
114
+ resolvedOrgId = project.orgId;
115
+ resolvedProjectId = project.id;
116
+ }
117
+ const rowId = await upsertInstallation({
118
+ orgId: resolvedOrgId,
119
+ projectId: resolvedProjectId,
120
+ installationId,
121
+ });
122
+ await resumeBlockedAgentRunsForOrg(resolvedOrgId, "github_install", {
123
+ projectIdHint: resolvedProjectId,
124
+ });
125
+ await maybeCaptureInstallerIdentity({
126
+ installationRowId: rowId,
127
+ installationId,
128
+ oauthCode,
129
+ clientId: oauthClientId,
130
+ clientSecret: oauthClientSecret,
131
+ redirectUrl: installRedirectUrl,
132
+ });
133
+ const successTarget = mgmt.returnUrl ?? `${callbackWebOrigin}/`;
134
+ return c.redirect(
135
+ `${successTarget}${successTarget.includes("?") ? "&" : "?"}gh=${marker}`,
136
+ 302,
137
+ );
138
+ }
139
+
140
+ const decoded = verifyState(state, stateSecret);
141
+ if (!decoded) return c.json({ error: "invalid state" }, 400);
142
+
143
+ // "cli" kind: value is userCode → resolve org+project from device.
144
+ // "web" kind: value is projectId → resolve org from the project row.
145
+ let orgId: string | null;
146
+ let projectId: string | null;
147
+ if (decoded.kind === "cli") {
148
+ const device = getLinkedDevice(decoded.value) ?? getSkillDeviceForIntegration(decoded.value);
149
+ orgId = device?.orgId ?? null;
150
+ projectId = device?.projectId ?? null;
151
+ } else {
152
+ const project = await db.query.projects.findFirst({
153
+ where: eq(schema.projects.id, decoded.value),
154
+ });
155
+ orgId = project?.orgId ?? null;
156
+ projectId = project?.id ?? null;
157
+ }
158
+
159
+ if (decoded.kind === "cli") {
160
+ const userCode = decoded.value;
161
+ const flow = getDeviceFlow(userCode);
162
+ const flowQuery = flow === "skill" ? "&flow=skill" : "";
163
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
164
+ if (!orgId || !projectId) {
165
+ return c.redirect(
166
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=expired`,
167
+ 302,
168
+ );
169
+ }
170
+ if (!Number.isFinite(installationId) || installationId <= 0) {
171
+ return c.redirect(
172
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=error`,
173
+ 302,
174
+ );
175
+ }
176
+ const rowId = await upsertInstallation({ orgId, projectId, installationId });
177
+ await resumeBlockedAgentRunsForProjects([projectId], "github_install");
178
+ await maybeCaptureInstallerIdentity({
179
+ installationRowId: rowId,
180
+ installationId,
181
+ oauthCode,
182
+ clientId: oauthClientId,
183
+ clientSecret: oauthClientSecret,
184
+ redirectUrl: installRedirectUrl,
185
+ });
186
+ return c.redirect(
187
+ `${callbackWebOrigin}/activate?code=${userCode}${flowQuery}&gh=${marker}`,
188
+ 302,
189
+ );
190
+ }
191
+
192
+ // decoded.kind === "web" — value resolved to project above.
193
+ const callbackWebOrigin = resolveCallbackWebOrigin(c, webOrigin);
194
+ if (!Number.isFinite(installationId) || installationId <= 0 || !orgId || !projectId) {
195
+ return c.redirect(`${callbackWebOrigin}/?gh=error`, 302);
196
+ }
197
+ const rowId = await upsertInstallation({ orgId, projectId, installationId });
198
+ await resumeBlockedAgentRunsForProjects([projectId], "github_install");
199
+ await maybeCaptureInstallerIdentity({
200
+ installationRowId: rowId,
201
+ installationId,
202
+ oauthCode,
203
+ clientId: oauthClientId,
204
+ clientSecret: oauthClientSecret,
205
+ redirectUrl: installRedirectUrl,
206
+ });
207
+ return c.redirect(`${callbackWebOrigin}/?gh=${marker}`, 302);
208
+ });
209
+
210
+ app.post("/github/webhook", async (c) => {
211
+ if (!webhookSecret) return c.json({ error: "webhook not configured" }, 503);
212
+
213
+ const rawBody = await c.req.text();
214
+ const sigHeader = c.req.header("x-hub-signature-256") ?? "";
215
+ if (!verifyWebhookSignature(rawBody, sigHeader, webhookSecret)) {
216
+ log.warn({ delivery: c.req.header("x-github-delivery") }, "github webhook signature failed");
217
+ return c.json({ error: "bad signature" }, 401);
218
+ }
219
+
220
+ const event = c.req.header("x-github-event") ?? "";
221
+ let payload: WebhookPayload;
222
+ try {
223
+ payload = JSON.parse(rawBody) as WebhookPayload;
224
+ } catch {
225
+ return c.json({ error: "invalid json" }, 400);
226
+ }
227
+
228
+ const delivery = c.req.header("x-github-delivery") ?? null;
229
+ try {
230
+ await handleWebhook(event, payload, delivery);
231
+ log.info(
232
+ {
233
+ event,
234
+ action: payload.action,
235
+ installation_id: payload.installation?.id,
236
+ delivery,
237
+ },
238
+ "github webhook handled",
239
+ );
240
+ } catch (err) {
241
+ log.error(
242
+ {
243
+ err,
244
+ event,
245
+ action: payload.action,
246
+ installation_id: payload.installation?.id,
247
+ },
248
+ "webhook handler failed",
249
+ );
250
+ // Let GitHub retry on transient failures.
251
+ return c.json({ error: "handler failed" }, 500);
252
+ }
253
+ return c.json({ ok: true });
254
+ });
255
+ }
256
+
257
+ function resolveCallbackWebOrigin(c: Context, configuredWebOrigin: string): string {
258
+ const host = c.req.header("host") ?? "";
259
+ if (
260
+ host === "localhost:4100" ||
261
+ host === "127.0.0.1:4100" ||
262
+ configuredWebOrigin.endsWith(".superlog.localhost:1355")
263
+ ) {
264
+ return "http://localhost:5173";
265
+ }
266
+ return configuredWebOrigin;
267
+ }
268
+
269
+ type GhRepo = { id: number; full_name: string; private: boolean };
270
+ export type StoredRepo = { id: number; fullName: string; private: boolean };
271
+ type RepoAccess = { disabledRepoIds?: number[] };
272
+ type GithubRepoSummary = StoredRepo & { enabled: boolean };
273
+ type GithubInstallationReposResponse = { repositories: GhRepo[] };
274
+ type GithubOAuthTokenResponse = {
275
+ access_token?: string;
276
+ token_type?: string;
277
+ scope?: string;
278
+ error?: string;
279
+ error_description?: string;
280
+ };
281
+ type GithubViewerResponse = {
282
+ id: number;
283
+ login: string;
284
+ name: string | null;
285
+ email: string | null;
286
+ avatar_url: string | null;
287
+ };
288
+ type GithubEmailResponse = {
289
+ email: string;
290
+ primary: boolean;
291
+ verified: boolean;
292
+ visibility: string | null;
293
+ };
294
+ type GithubUserInstallation = {
295
+ id: number;
296
+ account: { login?: string; type?: string } | null;
297
+ html_url: string;
298
+ repository_selection: "all" | "selected";
299
+ suspended_at: string | null;
300
+ };
301
+ type GithubUserInstallationsResponse = {
302
+ installations: GithubUserInstallation[];
303
+ };
304
+ type GithubUserInstallationReposResponse = {
305
+ repositories: Array<{ id: number; full_name: string; private: boolean }>;
306
+ };
307
+ type GithubInstallationSummary = {
308
+ installationId: number;
309
+ accountLogin: string | null;
310
+ accountType: string | null;
311
+ enabled: boolean;
312
+ manageUrl: string;
313
+ repos: GithubRepoSummary[];
314
+ };
315
+ type CommitAuthor = {
316
+ name: string;
317
+ email: string;
318
+ githubLogin: string;
319
+ githubId: number;
320
+ avatarUrl: string | null;
321
+ };
322
+ type GhActor = {
323
+ login?: string;
324
+ id?: number;
325
+ avatar_url?: string | null;
326
+ type?: string;
327
+ } | null;
328
+ type WebhookPayload = {
329
+ action?: string;
330
+ installation?: {
331
+ id: number;
332
+ account?: { login?: string; type?: string };
333
+ };
334
+ repositories?: GhRepo[];
335
+ repositories_added?: GhRepo[];
336
+ repositories_removed?: GhRepo[];
337
+ // pull_request events / pull_request_review / pull_request_review_comment
338
+ repository?: { full_name?: string };
339
+ pull_request?: {
340
+ number?: number;
341
+ node_id?: string;
342
+ state?: string;
343
+ title?: string;
344
+ merged?: boolean;
345
+ merged_at?: string | null;
346
+ closed_at?: string | null;
347
+ merged_by?: GhActor;
348
+ user?: GhActor;
349
+ head?: { sha?: string; ref?: string };
350
+ base?: { ref?: string };
351
+ html_url?: string;
352
+ };
353
+ // issue_comment (only PR if issue.pull_request present)
354
+ issue?: {
355
+ number?: number;
356
+ pull_request?: { url?: string };
357
+ };
358
+ comment?: {
359
+ id?: number;
360
+ body?: string;
361
+ user?: GhActor;
362
+ html_url?: string;
363
+ author_association?: string;
364
+ };
365
+ review?: {
366
+ id?: number;
367
+ state?: string;
368
+ body?: string | null;
369
+ user?: GhActor;
370
+ html_url?: string;
371
+ author_association?: string;
372
+ };
373
+ // push events
374
+ ref?: string;
375
+ after?: string;
376
+ before?: string;
377
+ pusher?: { name?: string; email?: string };
378
+ sender?: GhActor;
379
+ commits?: Array<{ id?: string; message?: string; author?: { name?: string; email?: string } }>;
380
+ };
381
+
382
+ async function handleWebhook(
383
+ event: string,
384
+ payload: WebhookPayload,
385
+ delivery: string | null,
386
+ ): Promise<void> {
387
+ if (
388
+ event === "pull_request" ||
389
+ event === "pull_request_review" ||
390
+ event === "pull_request_review_comment" ||
391
+ event === "issue_comment" ||
392
+ event === "push"
393
+ ) {
394
+ await handleAgentPrWebhook(event, payload, delivery);
395
+ return;
396
+ }
397
+
398
+ const installationId = payload.installation?.id;
399
+ if (!installationId) return;
400
+
401
+ if (event === "installation") {
402
+ const action = payload.action;
403
+ if (action === "created" || action === "new_permissions_accepted") {
404
+ await db
405
+ .update(schema.githubInstallations)
406
+ .set({
407
+ accountLogin: payload.installation?.account?.login ?? null,
408
+ accountType: payload.installation?.account?.type ?? null,
409
+ repos: [],
410
+ revokedAt: null,
411
+ })
412
+ .where(eq(schema.githubInstallations.installationId, installationId));
413
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_install");
414
+ return;
415
+ }
416
+ if (action === "deleted" || action === "suspend") {
417
+ await db
418
+ .update(schema.githubInstallations)
419
+ .set({ revokedAt: new Date() })
420
+ .where(eq(schema.githubInstallations.installationId, installationId));
421
+ return;
422
+ }
423
+ if (action === "unsuspend") {
424
+ await db
425
+ .update(schema.githubInstallations)
426
+ .set({ revokedAt: null })
427
+ .where(eq(schema.githubInstallations.installationId, installationId));
428
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_install");
429
+ return;
430
+ }
431
+ return;
432
+ }
433
+
434
+ if (event === "installation_repositories") {
435
+ const row = await db.query.githubInstallations.findFirst({
436
+ where: eq(schema.githubInstallations.installationId, installationId),
437
+ });
438
+ if (!row) return;
439
+ await db
440
+ .update(schema.githubInstallations)
441
+ .set({
442
+ repos: [],
443
+ accountLogin: payload.installation?.account?.login ?? row.accountLogin,
444
+ accountType: payload.installation?.account?.type ?? row.accountType,
445
+ })
446
+ .where(eq(schema.githubInstallations.installationId, installationId));
447
+ if (payload.action === "added") {
448
+ await resumeBlockedAgentRunsForInstallation(installationId, "github_repos_added");
449
+ }
450
+ }
451
+ }
452
+
453
+ // Requeue any agentRuns sitting in `blocked_no_github` under the given
454
+ // project(s). Cheap and idempotent — single bulk UPDATE + batched event
455
+ // inserts. Callers: install webhook (resolves project IDs from
456
+ // installationId) and the OAuth install callback (knows projectId directly,
457
+ // avoids a webhook/callback race on first-time installs where the github
458
+ // installations row may not exist yet when the webhook arrives).
459
+ export async function resumeBlockedAgentRunsForProjects(
460
+ projectIds: string[],
461
+ trigger: "github_install" | "github_repos_added",
462
+ ): Promise<void> {
463
+ if (projectIds.length === 0) return;
464
+ const blocked = await db
465
+ .select({ id: schema.agentRuns.id })
466
+ .from(schema.agentRuns)
467
+ .innerJoin(schema.incidents, eq(schema.incidents.id, schema.agentRuns.incidentId))
468
+ .where(
469
+ and(
470
+ eq(schema.agentRuns.state, "blocked_no_github"),
471
+ inArray(schema.incidents.projectId, projectIds),
472
+ ),
473
+ );
474
+ if (blocked.length === 0) return;
475
+
476
+ const ids = blocked.map((row) => row.id);
477
+ const now = new Date();
478
+ await db
479
+ .update(schema.agentRuns)
480
+ .set({ state: "queued", updatedAt: now })
481
+ .where(inArray(schema.agentRuns.id, ids));
482
+ await db
483
+ .insert(schema.incidentEvents)
484
+ .values(
485
+ ids.map((id) => ({
486
+ agentRunId: id,
487
+ kind: "unblocked",
488
+ summary: "Investigation requeued.",
489
+ detail: { trigger },
490
+ dedupeKey: `unblocked:${id}:${now.getTime()}`,
491
+ processedAt: now,
492
+ })),
493
+ )
494
+ .onConflictDoNothing();
495
+ log.info({ projectIds, trigger, count: ids.length }, "resumed blocked agent_runs");
496
+ }
497
+
498
+ // Org-scoped resume — fans out across every project in the org. Used for
499
+ // org-scoped installs where the install grants access to repos that any
500
+ // project can be wired up to. The optional `projectIdHint` is purely an
501
+ // optimisation: if the caller already knows one project should be revived,
502
+ // it's bundled into the same single query so we still issue one bulk update.
503
+ async function resumeBlockedAgentRunsForOrg(
504
+ orgId: string,
505
+ trigger: "github_install" | "github_repos_added",
506
+ opts: { projectIdHint?: string | null } = {},
507
+ ): Promise<void> {
508
+ const orgProjects = await db.query.projects.findMany({
509
+ where: eq(schema.projects.orgId, orgId),
510
+ columns: { id: true },
511
+ });
512
+ const projectIds = new Set(orgProjects.map((row) => row.id));
513
+ if (opts.projectIdHint) projectIds.add(opts.projectIdHint);
514
+ if (projectIds.size === 0) return;
515
+ await resumeBlockedAgentRunsForProjects([...projectIds], trigger);
516
+ }
517
+
518
+ async function resumeBlockedAgentRunsForInstallation(
519
+ installationId: number,
520
+ trigger: "github_install" | "github_repos_added",
521
+ ): Promise<void> {
522
+ const installs = await db.query.githubInstallations.findMany({
523
+ where: and(
524
+ eq(schema.githubInstallations.installationId, installationId),
525
+ isNull(schema.githubInstallations.revokedAt),
526
+ ),
527
+ columns: { orgId: true, projectId: true },
528
+ });
529
+ if (installs.length === 0) return;
530
+ // Project-scoped installs grant access narrowly — fan out to that project
531
+ // only. Org-scoped installs (projectId null) can grant repos to any project
532
+ // in the org, so widen to every project under the org.
533
+ const directProjectIds = installs
534
+ .map((row) => row.projectId)
535
+ .filter((id): id is string => id !== null);
536
+ if (directProjectIds.length > 0) {
537
+ await resumeBlockedAgentRunsForProjects(directProjectIds, trigger);
538
+ }
539
+ const orgIdsForOrgScoped = Array.from(
540
+ new Set(installs.filter((row) => row.projectId === null).map((row) => row.orgId)),
541
+ );
542
+ for (const orgId of orgIdsForOrgScoped) {
543
+ await resumeBlockedAgentRunsForOrg(orgId, trigger);
544
+ }
545
+ }
546
+
547
+ async function handleAgentPrWebhook(
548
+ event: string,
549
+ payload: WebhookPayload,
550
+ delivery: string | null,
551
+ ): Promise<void> {
552
+ const repoFullName = payload.repository?.full_name;
553
+ if (!repoFullName) return;
554
+
555
+ // Resolve which agent_pull_request row this event is about.
556
+ let prNumber: number | undefined;
557
+ if (
558
+ event === "pull_request" ||
559
+ event === "pull_request_review" ||
560
+ event === "pull_request_review_comment"
561
+ ) {
562
+ prNumber = payload.pull_request?.number;
563
+ } else if (event === "issue_comment") {
564
+ if (!payload.issue?.pull_request) return;
565
+ prNumber = payload.issue.number;
566
+ }
567
+ // push: resolve by branch
568
+ let agentPrRow: schema.AgentPullRequest | undefined;
569
+ if (event === "push") {
570
+ const ref = payload.ref;
571
+ if (!ref?.startsWith("refs/heads/")) return;
572
+ const branch = ref.slice("refs/heads/".length);
573
+ agentPrRow = await db.query.agentPullRequests.findFirst({
574
+ where: and(
575
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
576
+ eq(schema.agentPullRequests.branchName, branch),
577
+ ),
578
+ });
579
+ } else if (typeof prNumber === "number") {
580
+ agentPrRow = await db.query.agentPullRequests.findFirst({
581
+ where: and(
582
+ eq(schema.agentPullRequests.repoFullName, repoFullName),
583
+ eq(schema.agentPullRequests.prNumber, prNumber),
584
+ ),
585
+ });
586
+ }
587
+ if (!agentPrRow) return;
588
+
589
+ // Capture human review comments as feedback. We surface this in
590
+ // /admin/feedback so we can hear about how our agent-opened PRs are
591
+ // landing with the people reviewing them. Bot comments (greptile and
592
+ // friends) and the GitHub "review submitted" envelope (which has its
593
+ // own dedicated state machine in describeAgentPrEvent) are skipped.
594
+ //
595
+ // Best-effort: don't let a feedback DB error cascade into a 500 that
596
+ // makes GitHub retry the whole delivery. A retry would re-run the
597
+ // agent_pr_events insert (idempotent via onConflictDoNothing) and the
598
+ // PR state update, but a partial state where the feedback row landed
599
+ // before the retry-triggering throw is exactly the kind of "I see the
600
+ // feedback but not the merge-resolved incident" inconsistency we want
601
+ // to avoid. Matches the wrapping pattern used for Slack feedback in
602
+ // apps/api/src/slack.ts.
603
+ await maybeRecordPrCommentFeedback({ event, payload, agentPrRow }).catch((err) => {
604
+ log.warn({ err, event, agent_pr_id: agentPrRow.id }, "pr comment feedback capture failed");
605
+ });
606
+
607
+ const now = new Date();
608
+
609
+ // Update the parent row when this is a state-changing event.
610
+ if (event === "pull_request") {
611
+ const action = payload.action ?? "";
612
+ const pr = payload.pull_request ?? {};
613
+ let mergedResolution: { mergedAt: Date; mergedByLogin: string | null } | null = null;
614
+ const updates: Partial<typeof schema.agentPullRequests.$inferInsert> = {
615
+ lastSyncedAt: now,
616
+ updatedAt: now,
617
+ };
618
+ if (pr.head?.sha) updates.headSha = pr.head.sha;
619
+ if (typeof pr.title === "string") updates.title = pr.title;
620
+ if (action === "closed") {
621
+ if (pr.merged) {
622
+ updates.state = "merged";
623
+ if (pr.merged_at) updates.mergedAt = new Date(pr.merged_at);
624
+ updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now;
625
+ updates.mergedByLogin = pr.merged_by?.login ?? null;
626
+ updates.mergedByGithubId = pr.merged_by?.id ?? null;
627
+ mergedResolution = {
628
+ mergedAt: updates.mergedAt ?? updates.closedAt ?? now,
629
+ mergedByLogin: updates.mergedByLogin ?? null,
630
+ };
631
+ } else {
632
+ updates.state = "closed";
633
+ updates.closedAt = pr.closed_at ? new Date(pr.closed_at) : now;
634
+ }
635
+ } else if (action === "reopened") {
636
+ updates.state = "open";
637
+ updates.closedAt = null;
638
+ }
639
+ await db
640
+ .update(schema.agentPullRequests)
641
+ .set(updates)
642
+ .where(eq(schema.agentPullRequests.id, agentPrRow.id));
643
+ if (mergedResolution) {
644
+ await resolveIncidentForMergedAgentPr({
645
+ agentPr: agentPrRow,
646
+ mergedAt: mergedResolution.mergedAt,
647
+ mergedByLogin: mergedResolution.mergedByLogin,
648
+ });
649
+ }
650
+ } else if (event === "push") {
651
+ if (payload.after) {
652
+ await db
653
+ .update(schema.agentPullRequests)
654
+ .set({ headSha: payload.after, lastSyncedAt: now, updatedAt: now })
655
+ .where(eq(schema.agentPullRequests.id, agentPrRow.id));
656
+ }
657
+ }
658
+
659
+ const { kind, summary, actor } = describeAgentPrEvent(event, payload);
660
+ if (!kind) return;
661
+
662
+ await db
663
+ .insert(schema.agentPrEvents)
664
+ .values({
665
+ agentPrId: agentPrRow.id,
666
+ kind,
667
+ summary,
668
+ actorLogin: actor?.login ?? null,
669
+ actorGithubId: actor?.id ?? null,
670
+ actorAvatarUrl: actor?.avatar_url ?? null,
671
+ payload: payload as unknown as Record<string, unknown>,
672
+ providerEventId: delivery,
673
+ occurredAt: now,
674
+ })
675
+ .onConflictDoNothing();
676
+ }
677
+
678
+ async function resolveIncidentForMergedAgentPr(opts: {
679
+ agentPr: schema.AgentPullRequest;
680
+ mergedAt: Date;
681
+ mergedByLogin: string | null;
682
+ }): Promise<void> {
683
+ const { agentPr, mergedAt, mergedByLogin } = opts;
684
+ const { resolved } = await resolveIncident({
685
+ incidentId: agentPr.incidentId,
686
+ kind: "agent_pr_merged",
687
+ reasonCode: "agent_pr_merged",
688
+ reasonText: `Resolved because agent PR #${agentPr.prNumber} (${agentPr.repoFullName}) was merged${
689
+ mergedByLogin ? ` by @${mergedByLogin}` : ""
690
+ }.`,
691
+ agentRunId: agentPr.agentRunId,
692
+ eventSummary: `Incident resolved because PR #${agentPr.prNumber} was merged.`,
693
+ eventDetail: {
694
+ agentPrId: agentPr.id,
695
+ repoFullName: agentPr.repoFullName,
696
+ prNumber: agentPr.prNumber,
697
+ prUrl: agentPr.url,
698
+ mergedByLogin,
699
+ },
700
+ eventDedupeKey: `incident_resolved:agent_pr:${agentPr.id}`,
701
+ resolvedAt: mergedAt,
702
+ });
703
+ if (resolved) {
704
+ await runResolvedIncidentSideEffectsForIncident({
705
+ incidentId: agentPr.incidentId,
706
+ closePullRequest: (pr) =>
707
+ closeAgentPullRequestOnGithub({
708
+ installationId: pr.githubInstallationId,
709
+ repoFullName: pr.repoFullName,
710
+ prNumber: pr.prNumber,
711
+ }),
712
+ });
713
+ }
714
+ }
715
+
716
+ async function maybeRecordPrCommentFeedback(opts: {
717
+ event: string;
718
+ payload: WebhookPayload;
719
+ agentPrRow: schema.AgentPullRequest;
720
+ }): Promise<void> {
721
+ const { event, payload, agentPrRow } = opts;
722
+ // Only the comment-bearing events count; PR open/close/merge has its
723
+ // own first-class handling and isn't user feedback.
724
+ let body: string | null = null;
725
+ let actor: GhActor = null;
726
+ let authorAssociation: string | null = null;
727
+ let commentUrl: string | null = null;
728
+ if (event === "issue_comment" && payload.action === "created") {
729
+ body = payload.comment?.body ?? null;
730
+ actor = payload.comment?.user ?? null;
731
+ authorAssociation = payload.comment?.author_association ?? null;
732
+ commentUrl = payload.comment?.html_url ?? null;
733
+ } else if (event === "pull_request_review_comment" && payload.action === "created") {
734
+ body = payload.comment?.body ?? null;
735
+ actor = payload.comment?.user ?? null;
736
+ authorAssociation = payload.comment?.author_association ?? null;
737
+ commentUrl = payload.comment?.html_url ?? null;
738
+ } else if (event === "pull_request_review" && payload.action === "submitted") {
739
+ body = payload.review?.body ?? null;
740
+ actor = payload.review?.user ?? null;
741
+ authorAssociation = payload.review?.author_association ?? null;
742
+ commentUrl = payload.review?.html_url ?? null;
743
+ } else {
744
+ return;
745
+ }
746
+ if (!body || body.trim().length === 0) return;
747
+
748
+ // Skip our own footer echoed back by the GitHub UI — the PR description
749
+ // itself is rendered into comment-like contexts occasionally (e.g. the
750
+ // squashed merge commit message), and we don't want our own "leave
751
+ // feedback" link surfaced as feedback.
752
+ if (body.includes(FEEDBACK_PR_FOOTER_MARKER)) return;
753
+
754
+ if (
755
+ !isFeedbackEligibleCommenter({
756
+ userType: actor?.type ?? null,
757
+ authorAssociation,
758
+ })
759
+ ) {
760
+ return;
761
+ }
762
+
763
+ // Resolve org + project via the agent PR's installation so the admin
764
+ // inbox can show which org's PR this feedback is on.
765
+ const install = await db.query.githubInstallations.findFirst({
766
+ where: eq(schema.githubInstallations.id, agentPrRow.installationId),
767
+ });
768
+
769
+ // Best-effort idempotency: GitHub redelivers webhooks on failure (the
770
+ // outer handler returns 500 on its own catch path), so a single PR
771
+ // comment can hit us multiple times. The comment URL is a stable
772
+ // natural key — skip if we've already recorded feedback for it. Not a
773
+ // strict unique index because the dedupe window between delivery
774
+ // retries is minutes, not concurrent; a hard guarantee would need a
775
+ // unique partial index, which we may add later if duplicates show up.
776
+ if (commentUrl) {
777
+ const existing = await db.query.feedback.findFirst({
778
+ where: and(
779
+ eq(schema.feedback.source, "pr_comment"),
780
+ sql`${schema.feedback.authorExternal}->>'githubCommentUrl' = ${commentUrl}`,
781
+ ),
782
+ columns: { id: true },
783
+ });
784
+ if (existing) return;
785
+ }
786
+
787
+ await recordFeedback({
788
+ kind: "pr",
789
+ refId: agentPrRow.id,
790
+ refRepo: agentPrRow.repoFullName,
791
+ source: "pr_comment",
792
+ body,
793
+ authorUserId: null,
794
+ authorExternal: {
795
+ githubLogin: actor?.login,
796
+ githubCommentUrl: commentUrl ?? undefined,
797
+ },
798
+ orgId: install?.orgId ?? null,
799
+ projectId: install?.projectId ?? null,
800
+ });
801
+ }
802
+
803
+ function describeAgentPrEvent(
804
+ event: string,
805
+ payload: WebhookPayload,
806
+ ): { kind: string | null; summary: string | null; actor: GhActor } {
807
+ if (event === "pull_request") {
808
+ const action = payload.action ?? "";
809
+ const actor = payload.sender ?? payload.pull_request?.user ?? null;
810
+ if (action === "closed") {
811
+ const merged = payload.pull_request?.merged === true;
812
+ return {
813
+ kind: merged ? "pr_merged" : "pr_closed",
814
+ summary: merged ? "PR merged" : "PR closed",
815
+ actor: merged ? (payload.pull_request?.merged_by ?? actor) : actor,
816
+ };
817
+ }
818
+ if (action === "reopened") return { kind: "pr_reopened", summary: "PR reopened", actor };
819
+ if (action === "edited") return { kind: "pr_edited", summary: "PR edited", actor };
820
+ if (action === "ready_for_review") {
821
+ return { kind: "pr_ready_for_review", summary: "Marked ready for review", actor };
822
+ }
823
+ if (action === "converted_to_draft") {
824
+ return { kind: "pr_converted_to_draft", summary: "Converted to draft", actor };
825
+ }
826
+ return { kind: null, summary: null, actor: null };
827
+ }
828
+ if (event === "pull_request_review") {
829
+ if (payload.action !== "submitted") return { kind: null, summary: null, actor: null };
830
+ const state = payload.review?.state ?? "commented";
831
+ const summary = `Review submitted (${state})`;
832
+ return {
833
+ kind: `review_${state}`,
834
+ summary,
835
+ actor: payload.review?.user ?? payload.sender ?? null,
836
+ };
837
+ }
838
+ if (event === "pull_request_review_comment") {
839
+ if (payload.action !== "created") return { kind: null, summary: null, actor: null };
840
+ return {
841
+ kind: "review_comment",
842
+ summary: "Inline review comment",
843
+ actor: payload.comment?.user ?? payload.sender ?? null,
844
+ };
845
+ }
846
+ if (event === "issue_comment") {
847
+ if (payload.action !== "created") return { kind: null, summary: null, actor: null };
848
+ return {
849
+ kind: "issue_comment",
850
+ summary: "Comment on PR",
851
+ actor: payload.comment?.user ?? payload.sender ?? null,
852
+ };
853
+ }
854
+ if (event === "push") {
855
+ const count = payload.commits?.length ?? 0;
856
+ return {
857
+ kind: "commit_pushed",
858
+ summary: `${count} commit${count === 1 ? "" : "s"} pushed`,
859
+ actor: payload.sender ?? null,
860
+ };
861
+ }
862
+ return { kind: null, summary: null, actor: null };
863
+ }
864
+
865
+ function toStoredRepo(r: GhRepo): StoredRepo {
866
+ return { id: r.id, fullName: r.full_name, private: r.private };
867
+ }
868
+
869
+ // Upserts an installation row. Returns the row's PK id so callers can patch
870
+ // it (e.g. capture installer commit author).
871
+ //
872
+ // Both project- and org-scoped shapes use a partial unique index on the
873
+ // active rows for their respective key tuple. onConflict targets need the
874
+ // same WHERE clause as the index so postgres can match.
875
+ //
876
+ // The `set` payloads are effectively no-ops (any conflicting row already
877
+ // has revoked_at IS NULL by virtue of the partial-index WHERE clause; org_id
878
+ // is determined by project_id for the project-scoped case). They exist only
879
+ // because `DO UPDATE` makes RETURNING fire for the existing row, where
880
+ // `DO NOTHING` would return no row and force a second SELECT to get the id.
881
+ async function upsertInstallation(args: {
882
+ orgId: string;
883
+ projectId: string | null;
884
+ installationId: number;
885
+ }): Promise<string> {
886
+ if (args.projectId) {
887
+ const [row] = await db
888
+ .insert(schema.githubInstallations)
889
+ .values({
890
+ orgId: args.orgId,
891
+ projectId: args.projectId,
892
+ installationId: args.installationId,
893
+ })
894
+ .onConflictDoUpdate({
895
+ target: [schema.githubInstallations.projectId, schema.githubInstallations.installationId],
896
+ targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`,
897
+ set: { orgId: args.orgId, revokedAt: null },
898
+ })
899
+ .returning({ id: schema.githubInstallations.id });
900
+ if (!row) throw new Error("upsert returned no row");
901
+ return row.id;
902
+ }
903
+ const [row] = await db
904
+ .insert(schema.githubInstallations)
905
+ .values({
906
+ orgId: args.orgId,
907
+ projectId: null,
908
+ installationId: args.installationId,
909
+ })
910
+ .onConflictDoUpdate({
911
+ target: [schema.githubInstallations.orgId, schema.githubInstallations.installationId],
912
+ targetWhere: sql`project_id IS NULL AND revoked_at IS NULL`,
913
+ set: { revokedAt: null },
914
+ })
915
+ .returning({ id: schema.githubInstallations.id });
916
+ if (!row) throw new Error("insert returned no row");
917
+ return row.id;
918
+ }
919
+
920
+ async function maybeCaptureInstallerIdentity(opts: {
921
+ installationRowId: string;
922
+ installationId: number;
923
+ oauthCode: string | null;
924
+ clientId: string | undefined;
925
+ clientSecret: string | undefined;
926
+ redirectUrl: string;
927
+ }): Promise<void> {
928
+ if (!opts.oauthCode || !opts.clientId || !opts.clientSecret) return;
929
+ try {
930
+ const token = await exchangeGithubOAuthCode({
931
+ clientId: opts.clientId,
932
+ clientSecret: opts.clientSecret,
933
+ code: opts.oauthCode,
934
+ redirectUrl: opts.redirectUrl,
935
+ });
936
+ const author = await fetchCommitAuthor(token);
937
+ await db
938
+ .update(schema.githubInstallations)
939
+ .set({
940
+ commitAuthorName: author.name,
941
+ commitAuthorEmail: author.email,
942
+ commitAuthorGithubLogin: author.githubLogin,
943
+ commitAuthorGithubId: author.githubId,
944
+ commitAuthorAvatarUrl: author.avatarUrl,
945
+ commitAuthorSetAt: new Date(),
946
+ })
947
+ .where(eq(schema.githubInstallations.id, opts.installationRowId));
948
+ log.info(
949
+ {
950
+ installation_row_id: opts.installationRowId,
951
+ installation_id: opts.installationId,
952
+ github_login: author.githubLogin,
953
+ },
954
+ "captured installer commit author from install OAuth",
955
+ );
956
+ } catch (err) {
957
+ log.warn(
958
+ { err, installation_row_id: opts.installationRowId, installation_id: opts.installationId },
959
+ "failed to capture installer commit author; falling back to default",
960
+ );
961
+ }
962
+ }
963
+
964
+ function verifyWebhookSignature(body: string, header: string, secret: string): boolean {
965
+ if (!header.startsWith("sha256=")) return false;
966
+ const provided = Buffer.from(header.slice("sha256=".length), "hex");
967
+ const expected = crypto.createHmac("sha256", secret).update(body).digest();
968
+ if (provided.length !== expected.length) return false;
969
+ return crypto.timingSafeEqual(provided, expected);
970
+ }
971
+
972
+ function getGithubAppConfig(): { appId: string; privateKey: string } | null {
973
+ const appId = process.env.GITHUB_APP_ID?.trim();
974
+ const privateKey =
975
+ process.env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, "\n") ??
976
+ (process.env.GITHUB_APP_PRIVATE_KEY_BASE64
977
+ ? Buffer.from(process.env.GITHUB_APP_PRIVATE_KEY_BASE64, "base64").toString("utf8")
978
+ : undefined);
979
+ if (!appId || !privateKey) return null;
980
+ return { appId, privateKey };
981
+ }
982
+
983
+ function signGithubAppJwt(appId: string, privateKey: string): string {
984
+ const now = Math.floor(Date.now() / 1000);
985
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
986
+ const payload = Buffer.from(
987
+ JSON.stringify({
988
+ iat: now - 60,
989
+ exp: now + 9 * 60,
990
+ iss: appId,
991
+ }),
992
+ ).toString("base64url");
993
+ const signingInput = `${header}.${payload}`;
994
+ const signature = crypto.sign("RSA-SHA256", Buffer.from(signingInput), privateKey);
995
+ return `${signingInput}.${signature.toString("base64url")}`;
996
+ }
997
+
998
+ async function githubRequest<T>(pathname: string, bearerToken: string): Promise<T> {
999
+ const res = await fetch(`https://api.github.com${pathname}`, {
1000
+ headers: {
1001
+ accept: "application/vnd.github+json",
1002
+ authorization: `Bearer ${bearerToken}`,
1003
+ "x-github-api-version": "2022-11-28",
1004
+ "user-agent": "superlog-api",
1005
+ },
1006
+ });
1007
+ if (!res.ok) {
1008
+ const text = await res.text().catch(() => "");
1009
+ throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`);
1010
+ }
1011
+ return (await res.json()) as T;
1012
+ }
1013
+
1014
+ type GithubPermission = "read" | "write";
1015
+
1016
+ async function createInstallationToken(opts: {
1017
+ installationId: number;
1018
+ permissions: Record<string, GithubPermission>;
1019
+ }): Promise<string> {
1020
+ const cfg = getGithubAppConfig();
1021
+ if (!cfg) throw new Error("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY are required");
1022
+ const appJwt = signGithubAppJwt(cfg.appId, cfg.privateKey);
1023
+ const res = await fetch(
1024
+ `https://api.github.com/app/installations/${opts.installationId}/access_tokens`,
1025
+ {
1026
+ method: "POST",
1027
+ headers: {
1028
+ accept: "application/vnd.github+json",
1029
+ authorization: `Bearer ${appJwt}`,
1030
+ "content-type": "application/json; charset=utf-8",
1031
+ "x-github-api-version": "2022-11-28",
1032
+ "user-agent": "superlog-api",
1033
+ },
1034
+ body: JSON.stringify({ permissions: opts.permissions }),
1035
+ },
1036
+ );
1037
+ if (!res.ok) {
1038
+ const text = await res.text().catch(() => "");
1039
+ throw new Error(
1040
+ `github POST /app/installations/${opts.installationId}/access_tokens failed: ${res.status} ${text}`,
1041
+ );
1042
+ }
1043
+ const data = (await res.json()) as { token: string };
1044
+ return data.token;
1045
+ }
1046
+
1047
+ async function createInstallationReadToken(installationId: number): Promise<string> {
1048
+ return createInstallationToken({ installationId, permissions: { contents: "read" } });
1049
+ }
1050
+
1051
+ async function createInstallationWriteToken(installationId: number): Promise<string> {
1052
+ return createInstallationToken({
1053
+ installationId,
1054
+ permissions: { contents: "write", pull_requests: "write" },
1055
+ });
1056
+ }
1057
+
1058
+ export async function mergeGithubPullRequest(opts: {
1059
+ installationId: number;
1060
+ repoFullName: string;
1061
+ prNumber: number;
1062
+ method: "squash" | "merge" | "rebase";
1063
+ }): Promise<{ sha: string | null }> {
1064
+ const token = await createInstallationWriteToken(opts.installationId);
1065
+ const res = await fetch(
1066
+ `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}/merge`,
1067
+ {
1068
+ method: "PUT",
1069
+ headers: {
1070
+ accept: "application/vnd.github+json",
1071
+ authorization: `Bearer ${token}`,
1072
+ "content-type": "application/json; charset=utf-8",
1073
+ "x-github-api-version": "2022-11-28",
1074
+ "user-agent": "superlog-api",
1075
+ },
1076
+ body: JSON.stringify({ merge_method: opts.method }),
1077
+ },
1078
+ );
1079
+ if (!res.ok) {
1080
+ const text = await res.text().catch(() => "");
1081
+ throw new Error(`github PUT /pulls/${opts.prNumber}/merge failed: ${res.status} ${text}`);
1082
+ }
1083
+ const body = (await res.json().catch(() => ({}))) as { sha?: string };
1084
+ return { sha: body.sha ?? null };
1085
+ }
1086
+
1087
+ export async function closeAgentPullRequestOnGithub(opts: {
1088
+ installationId: number;
1089
+ repoFullName: string;
1090
+ prNumber: number;
1091
+ }): Promise<{ ok: true } | { ok: false; error: string }> {
1092
+ try {
1093
+ const token = await createInstallationWriteToken(opts.installationId);
1094
+ const res = await fetch(
1095
+ `https://api.github.com/repos/${opts.repoFullName}/pulls/${opts.prNumber}`,
1096
+ {
1097
+ method: "PATCH",
1098
+ headers: {
1099
+ accept: "application/vnd.github+json",
1100
+ authorization: `Bearer ${token}`,
1101
+ "content-type": "application/json; charset=utf-8",
1102
+ "x-github-api-version": "2022-11-28",
1103
+ "user-agent": "superlog-api",
1104
+ },
1105
+ body: JSON.stringify({ state: "closed" }),
1106
+ },
1107
+ );
1108
+ if (res.ok) return { ok: true };
1109
+ const text = await res.text().catch(() => "");
1110
+ return { ok: false, error: `github PATCH /pulls/${opts.prNumber} ${res.status} ${text}` };
1111
+ } catch (err) {
1112
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1113
+ }
1114
+ }
1115
+
1116
+ // Safety ceiling on paginated GitHub list calls: 100 pages × 100 per page =
1117
+ // 10k items. High enough to cover every realistic repo/branch count while
1118
+ // still bounding the loop so a pathological account can't spin it forever.
1119
+ const GITHUB_LIST_MAX_PAGES = 100;
1120
+
1121
+ export async function listCurrentInstallationRepos(installationId: number): Promise<StoredRepo[]> {
1122
+ const token = await createInstallationReadToken(installationId);
1123
+ const repos: StoredRepo[] = [];
1124
+ let page = 1;
1125
+ for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) {
1126
+ const data = await githubRequest<GithubInstallationReposResponse>(
1127
+ `/installation/repositories?per_page=100&page=${page}`,
1128
+ token,
1129
+ );
1130
+ repos.push(...data.repositories.map(toStoredRepo));
1131
+ if (data.repositories.length < 100) break;
1132
+ }
1133
+ if (page > GITHUB_LIST_MAX_PAGES) {
1134
+ log.warn(
1135
+ { installationId, cap: GITHUB_LIST_MAX_PAGES * 100 },
1136
+ "installation repo list hit the pagination cap; results may be truncated",
1137
+ );
1138
+ }
1139
+ return repos;
1140
+ }
1141
+
1142
+ // Direct, O(1) repo lookup that doesn't depend on paginating the install's
1143
+ // full repo list. Used by the management-API grant flow: a 1000+ repo install
1144
+ // would otherwise silently 404 anything past page 10 of the list. Returns
1145
+ // null if the install's token can't see this repo (i.e. not covered).
1146
+ export async function fetchInstallationRepoById(
1147
+ installationId: number,
1148
+ repoId: number,
1149
+ ): Promise<StoredRepo | null> {
1150
+ const token = await createInstallationReadToken(installationId);
1151
+ try {
1152
+ const data = await githubRequest<{ id: number; full_name: string; private: boolean }>(
1153
+ `/repositories/${repoId}`,
1154
+ token,
1155
+ );
1156
+ return { id: data.id, fullName: data.full_name, private: data.private };
1157
+ } catch (err) {
1158
+ if (
1159
+ err instanceof Error &&
1160
+ (err.message.includes("failed: 404") || err.message.includes("failed: 403"))
1161
+ ) {
1162
+ return null;
1163
+ }
1164
+ throw err;
1165
+ }
1166
+ }
1167
+
1168
+ async function fetchRepoBranchInfo(
1169
+ installationId: number,
1170
+ repoFullName: string,
1171
+ ): Promise<RepoBranchInfo> {
1172
+ const token = await createInstallationReadToken(installationId);
1173
+ const repo = await githubRequest<{ default_branch?: string }>(`/repos/${repoFullName}`, token);
1174
+ const branches: string[] = [];
1175
+ let page = 1;
1176
+ for (; page <= GITHUB_LIST_MAX_PAGES; page += 1) {
1177
+ const data = await githubRequest<{ name: string }[]>(
1178
+ `/repos/${repoFullName}/branches?per_page=100&page=${page}`,
1179
+ token,
1180
+ );
1181
+ branches.push(...data.map((b) => b.name));
1182
+ if (data.length < 100) break;
1183
+ }
1184
+ if (page > GITHUB_LIST_MAX_PAGES) {
1185
+ log.warn(
1186
+ { repo: repoFullName, cap: GITHUB_LIST_MAX_PAGES * 100 },
1187
+ "repo branch list hit the pagination cap; results may be truncated",
1188
+ );
1189
+ }
1190
+ return { defaultBranch: repo.default_branch ?? null, branches };
1191
+ }
1192
+
1193
+ // Lists the branch set the agent could target for a project: the union of
1194
+ // branches across every enabled repo the project's GitHub installation(s) can
1195
+ // reach. `errored` is true when there were repos to inspect but every lookup
1196
+ // failed (token/network), so callers can disable the picker instead of
1197
+ // pretending the repo simply has no branches.
1198
+ export async function listProjectRepoBranches(
1199
+ projectId: string,
1200
+ ): Promise<{ branches: RepoBranch[]; errored: boolean }> {
1201
+ const accessible = await listAccessibleGithubInstallsForProject(projectId);
1202
+ const perRepo: RepoBranchInfo[] = [];
1203
+ const seenRepos = new Set<string>();
1204
+ let sawError = false;
1205
+ for (const { installation: row, allowedRepoIds } of accessible) {
1206
+ if (!row.agentEnabled) continue;
1207
+ let repos: StoredRepo[];
1208
+ try {
1209
+ repos = await listCurrentInstallationRepos(row.installationId);
1210
+ } catch (err) {
1211
+ sawError = true;
1212
+ log.warn(
1213
+ { err, projectId, installationId: row.installationId },
1214
+ "github repository listing failed while loading branches",
1215
+ );
1216
+ continue;
1217
+ }
1218
+ const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds);
1219
+ const repoAccess = normalizeRepoAccess(row.repoAccess);
1220
+ for (const repo of repos) {
1221
+ if (grantSet && !grantSet.has(repo.id)) continue;
1222
+ if (!isRepoEnabled(repoAccess, repo.id)) continue;
1223
+ if (seenRepos.has(repo.fullName)) continue;
1224
+ seenRepos.add(repo.fullName);
1225
+ try {
1226
+ perRepo.push(await fetchRepoBranchInfo(row.installationId, repo.fullName));
1227
+ } catch (err) {
1228
+ sawError = true;
1229
+ log.warn({ err, projectId, repo: repo.fullName }, "github branch listing failed for repo");
1230
+ }
1231
+ }
1232
+ }
1233
+ // Distinguish "GitHub call failed" from "project genuinely has no branches":
1234
+ // only flag errored when something broke AND we produced nothing, so a
1235
+ // partial failure still returns the branches we did manage to load.
1236
+ return { branches: mergeRepoBranches(perRepo), errored: sawError && perRepo.length === 0 };
1237
+ }
1238
+
1239
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
1240
+ export function mountGithubAuthed(app: Hono<any>): void {
1241
+ const appSlug = process.env.GITHUB_APP_SLUG;
1242
+ const oauthClientId =
1243
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
1244
+ process.env.GITHUB_APP_CLIENT_ID ??
1245
+ process.env.GITHUB_CLIENT_ID;
1246
+ const oauthClientSecret =
1247
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
1248
+ process.env.GITHUB_APP_CLIENT_SECRET ??
1249
+ process.env.GITHUB_CLIENT_SECRET;
1250
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
1251
+ const authorRedirectUrl =
1252
+ process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ??
1253
+ `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`;
1254
+
1255
+ async function resolveActiveContext(
1256
+ userId: string | undefined,
1257
+ preferredOrgId?: string | null,
1258
+ ): Promise<{ orgId: string; projectId: string } | null> {
1259
+ if (!userId) return null;
1260
+ const ctx = await resolveActiveOrgContext({
1261
+ userId,
1262
+ preferredOrgId: preferredOrgId ?? null,
1263
+ }).catch(() => null);
1264
+ if (!ctx) return null;
1265
+ return { orgId: ctx.org.id, projectId: ctx.project.id };
1266
+ }
1267
+
1268
+ app.get("/api/github/installation", async (c) => {
1269
+ const active = await resolveActiveContext(
1270
+ c.var.userId as string | undefined,
1271
+ c.var.orgId as string | null | undefined,
1272
+ );
1273
+ if (!active) return c.json({ installed: false });
1274
+
1275
+ const accessible = await listAccessibleGithubInstallsForProject(active.projectId);
1276
+ if (accessible.length === 0) return c.json({ installed: false });
1277
+
1278
+ const installations: GithubInstallationSummary[] = [];
1279
+ let repoVerificationUnavailable = false;
1280
+ for (const { installation: row, allowedRepoIds } of accessible) {
1281
+ let repos = Array.isArray(row.repos) ? (row.repos as StoredRepo[]) : [];
1282
+ try {
1283
+ repos = await listCurrentInstallationRepos(row.installationId);
1284
+ } catch (err) {
1285
+ log.warn(
1286
+ { err, projectId: active.projectId, installationId: row.installationId },
1287
+ "github repository listing failed",
1288
+ );
1289
+ if (
1290
+ err instanceof Error &&
1291
+ err.message.includes("GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY")
1292
+ ) {
1293
+ repoVerificationUnavailable = true;
1294
+ }
1295
+ if (isMissingGithubInstallation(err)) {
1296
+ await db
1297
+ .update(schema.githubInstallations)
1298
+ .set({ revokedAt: new Date() })
1299
+ .where(eq(schema.githubInstallations.id, row.id));
1300
+ continue;
1301
+ }
1302
+ }
1303
+ const repoAccess = normalizeRepoAccess(row.repoAccess);
1304
+ // Granted org-scoped installs are restricted to the project's allowed
1305
+ // repo set; project-owned installs see everything in the install.
1306
+ const grantSet = allowedRepoIds === null ? null : new Set(allowedRepoIds);
1307
+ const scopedRepos = grantSet === null ? repos : repos.filter((repo) => grantSet.has(repo.id));
1308
+ installations.push({
1309
+ installationId: row.installationId,
1310
+ accountLogin: row.accountLogin,
1311
+ accountType: row.accountType,
1312
+ enabled: row.agentEnabled,
1313
+ manageUrl: githubInstallationManageUrl(row),
1314
+ repos: scopedRepos.map((repo) => ({
1315
+ ...repo,
1316
+ enabled: isRepoEnabled(repoAccess, repo.id),
1317
+ })),
1318
+ });
1319
+ }
1320
+ if (installations.length === 0) return c.json({ installed: false });
1321
+
1322
+ const authorRow = accessible
1323
+ .map(({ installation }) => installation)
1324
+ .find((row) => row.commitAuthorName && row.commitAuthorEmail);
1325
+ const repos = dedupeRepoSummaries(
1326
+ installations.flatMap((installation) =>
1327
+ installation.repos.map((repo) => ({
1328
+ ...repo,
1329
+ enabled: installation.enabled && repo.enabled,
1330
+ })),
1331
+ ),
1332
+ );
1333
+ const firstInstallation = installations[0];
1334
+ if (!firstInstallation) return c.json({ installed: false });
1335
+ return c.json({
1336
+ installed: true,
1337
+ installationId: firstInstallation.installationId,
1338
+ accountLogin: firstInstallation.accountLogin,
1339
+ manageUrl: firstInstallation.manageUrl,
1340
+ repoVerificationUnavailable,
1341
+ installations,
1342
+ repos,
1343
+ commitAuthor:
1344
+ authorRow?.commitAuthorName && authorRow.commitAuthorEmail
1345
+ ? {
1346
+ source: "github_user",
1347
+ name: authorRow.commitAuthorName,
1348
+ email: authorRow.commitAuthorEmail,
1349
+ githubLogin: authorRow.commitAuthorGithubLogin,
1350
+ githubId: authorRow.commitAuthorGithubId,
1351
+ avatarUrl: authorRow.commitAuthorAvatarUrl,
1352
+ setAt: authorRow.commitAuthorSetAt?.toISOString() ?? null,
1353
+ }
1354
+ : {
1355
+ source: "app",
1356
+ name: DEFAULT_COMMIT_AUTHOR.name,
1357
+ email: DEFAULT_COMMIT_AUTHOR.email,
1358
+ githubLogin: null,
1359
+ githubId: null,
1360
+ avatarUrl: null,
1361
+ setAt: null,
1362
+ },
1363
+ });
1364
+ });
1365
+
1366
+ app.post("/api/github/skip", async (c) => {
1367
+ const ctx = await resolveUserOrg(c);
1368
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
1369
+ await db
1370
+ .update(schema.orgs)
1371
+ .set({ githubSetupSkippedAt: new Date() })
1372
+ .where(eq(schema.orgs.id, ctx.orgId));
1373
+ return c.json({ ok: true, orgId: ctx.orgId });
1374
+ });
1375
+
1376
+ // Lands here after Better Auth completes GitHub OAuth. If the user's org
1377
+ // already has a GitHub App install we redirect back to the web app; if not,
1378
+ // we kick off the App install flow inline so signing in with GitHub
1379
+ // doubles as installing the App. The callbackURL passed from the web
1380
+ // client points at this endpoint; the cookie session is set by Better Auth
1381
+ // before the redirect lands here so `c.var.userId` is populated by the
1382
+ // /api/* middleware.
1383
+ app.get("/api/github/post-signin", async (c) => {
1384
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
1385
+ const active = await resolveActiveContext(
1386
+ c.var.userId as string | undefined,
1387
+ c.var.orgId as string | null | undefined,
1388
+ );
1389
+ if (!active) return c.redirect(`${webOrigin}/`, 302);
1390
+
1391
+ const installs = await listAccessibleGithubInstallsForProject(active.projectId);
1392
+ if (installs.length > 0) return c.redirect(`${webOrigin}/`, 302);
1393
+
1394
+ if (!appSlug || !stateSecret) {
1395
+ // GitHub app not configured in this env — just send the user home so
1396
+ // they aren't stuck on a blank page.
1397
+ return c.redirect(`${webOrigin}/`, 302);
1398
+ }
1399
+ const state = signState("web", active.projectId, stateSecret);
1400
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
1401
+ url.searchParams.set("state", state);
1402
+ return c.redirect(url.toString(), 302);
1403
+ });
1404
+
1405
+ app.post("/api/github/install-url", async (c) => {
1406
+ if (!appSlug || !stateSecret) {
1407
+ return c.json({ error: "github app not configured" }, 503);
1408
+ }
1409
+ const active = await resolveActiveContext(
1410
+ c.var.userId as string | undefined,
1411
+ c.var.orgId as string | null | undefined,
1412
+ );
1413
+ if (!active) return c.json({ error: "no org for user" }, 404);
1414
+
1415
+ // "web" state value is now the projectId; the callback resolves the org
1416
+ // from the project row.
1417
+ const state = signState("web", active.projectId, stateSecret);
1418
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
1419
+ url.searchParams.set("state", state);
1420
+ log.info(
1421
+ { org_id: active.orgId, project_id: active.projectId, app_slug: appSlug },
1422
+ "github install url created",
1423
+ );
1424
+ return c.json({ url: url.toString() });
1425
+ });
1426
+
1427
+ app.post("/api/github/repo-access", async (c) => {
1428
+ const ctx = await resolveUserOrg(c);
1429
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
1430
+
1431
+ const body = (await c.req.json().catch(() => null)) as {
1432
+ installationId?: unknown;
1433
+ enabled?: unknown;
1434
+ repoId?: unknown;
1435
+ repoEnabled?: unknown;
1436
+ } | null;
1437
+ const installationId = Number(body?.installationId);
1438
+ if (!Number.isFinite(installationId) || installationId <= 0) {
1439
+ return c.json({ error: "invalid installation id" }, 400);
1440
+ }
1441
+
1442
+ const row = await db.query.githubInstallations.findFirst({
1443
+ where: and(
1444
+ eq(schema.githubInstallations.projectId, ctx.projectId),
1445
+ eq(schema.githubInstallations.installationId, installationId),
1446
+ isNull(schema.githubInstallations.revokedAt),
1447
+ ),
1448
+ });
1449
+ if (!row) return c.json({ error: "github installation not found" }, 404);
1450
+
1451
+ const patch: {
1452
+ agentEnabled?: boolean;
1453
+ repoAccess?: RepoAccess;
1454
+ } = {};
1455
+ if (typeof body?.enabled === "boolean") {
1456
+ patch.agentEnabled = body.enabled;
1457
+ }
1458
+ if (body?.repoId !== undefined || body?.repoEnabled !== undefined) {
1459
+ const repoId = Number(body?.repoId);
1460
+ if (!Number.isFinite(repoId) || repoId <= 0 || typeof body?.repoEnabled !== "boolean") {
1461
+ return c.json({ error: "invalid repo access update" }, 400);
1462
+ }
1463
+ patch.repoAccess = setRepoEnabled(row.repoAccess, repoId, body.repoEnabled);
1464
+ }
1465
+ if (Object.keys(patch).length === 0) {
1466
+ return c.json({ error: "no repo access change provided" }, 400);
1467
+ }
1468
+
1469
+ await db
1470
+ .update(schema.githubInstallations)
1471
+ .set(patch)
1472
+ .where(eq(schema.githubInstallations.id, row.id));
1473
+ return c.json({ ok: true });
1474
+ });
1475
+
1476
+ app.post("/api/github/author-login-url", async (c) => {
1477
+ const ctx = await resolveUserOrg(c);
1478
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
1479
+
1480
+ if (!oauthClientId || !oauthClientSecret || !stateSecret) {
1481
+ if (!appSlug || !stateSecret) {
1482
+ return c.json({ error: "github oauth not configured" }, 503);
1483
+ }
1484
+ log.warn(
1485
+ { org_id: ctx.orgId, project_id: ctx.projectId },
1486
+ "github oauth not configured; falling back to app installation flow",
1487
+ );
1488
+ const state = signState("web", ctx.projectId, stateSecret);
1489
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
1490
+ url.searchParams.set("state", state);
1491
+ return c.json({ url: url.toString() });
1492
+ }
1493
+
1494
+ const state = signAuthorState(
1495
+ { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "author" },
1496
+ stateSecret,
1497
+ );
1498
+ const url = new URL("https://github.com/login/oauth/authorize");
1499
+ url.searchParams.set("client_id", oauthClientId);
1500
+ url.searchParams.set("redirect_uri", authorRedirectUrl);
1501
+ url.searchParams.set("scope", "read:user user:email");
1502
+ url.searchParams.set("state", state);
1503
+ return c.json({ url: url.toString() });
1504
+ });
1505
+
1506
+ app.post("/api/github/access-login-url", async (c) => {
1507
+ const ctx = await resolveUserOrg(c);
1508
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
1509
+
1510
+ if (!oauthClientId || !oauthClientSecret || !stateSecret) {
1511
+ if (!appSlug || !stateSecret) {
1512
+ return c.json({ error: "github oauth not configured" }, 503);
1513
+ }
1514
+ log.warn(
1515
+ { org_id: ctx.orgId, project_id: ctx.projectId },
1516
+ "github oauth not configured; falling back to app installation flow",
1517
+ );
1518
+ const state = signState("web", ctx.projectId, stateSecret);
1519
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
1520
+ url.searchParams.set("state", state);
1521
+ return c.json({ url: url.toString() });
1522
+ }
1523
+
1524
+ const state = signAuthorState(
1525
+ { orgId: ctx.orgId, projectId: ctx.projectId, userId: ctx.userId, purpose: "access" },
1526
+ stateSecret,
1527
+ );
1528
+ const url = new URL("https://github.com/login/oauth/authorize");
1529
+ url.searchParams.set("client_id", oauthClientId);
1530
+ url.searchParams.set("redirect_uri", authorRedirectUrl);
1531
+ url.searchParams.set("scope", "read:user user:email");
1532
+ url.searchParams.set("state", state);
1533
+ return c.json({ url: url.toString() });
1534
+ });
1535
+
1536
+ app.post("/api/github/commit-author/reset", async (c) => {
1537
+ const ctx = await resolveUserOrg(c);
1538
+ if (!ctx) return c.json({ error: "no org for user" }, 404);
1539
+
1540
+ await db
1541
+ .update(schema.githubInstallations)
1542
+ .set({
1543
+ commitAuthorName: null,
1544
+ commitAuthorEmail: null,
1545
+ commitAuthorGithubLogin: null,
1546
+ commitAuthorGithubId: null,
1547
+ commitAuthorAvatarUrl: null,
1548
+ commitAuthorSetByUserId: null,
1549
+ commitAuthorSetAt: null,
1550
+ })
1551
+ .where(
1552
+ and(
1553
+ eq(schema.githubInstallations.projectId, ctx.projectId),
1554
+ isNull(schema.githubInstallations.revokedAt),
1555
+ ),
1556
+ );
1557
+ return c.json({ ok: true });
1558
+ });
1559
+ }
1560
+
1561
+ // biome-ignore lint/suspicious/noExplicitAny: Hono Variables invariance.
1562
+ export function mountGithubAuthorOAuth(app: Hono<any>): void {
1563
+ const clientId =
1564
+ process.env.GITHUB_OAUTH_CLIENT_ID ??
1565
+ process.env.GITHUB_APP_CLIENT_ID ??
1566
+ process.env.GITHUB_CLIENT_ID;
1567
+ const clientSecret =
1568
+ process.env.GITHUB_OAUTH_CLIENT_SECRET ??
1569
+ process.env.GITHUB_APP_CLIENT_SECRET ??
1570
+ process.env.GITHUB_CLIENT_SECRET;
1571
+ const redirectUrl =
1572
+ process.env.GITHUB_AUTHOR_OAUTH_REDIRECT_URL ??
1573
+ `${process.env.BETTER_AUTH_URL ?? "http://localhost:4100"}/github/author/callback`;
1574
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
1575
+ const webOrigin = process.env.WEB_ORIGIN ?? "http://localhost:5173";
1576
+
1577
+ if (!clientId || !clientSecret) {
1578
+ log.warn("GITHUB_OAUTH_CLIENT_ID/SECRET not set — /github/author/callback disabled");
1579
+ }
1580
+
1581
+ app.get("/github/author/callback", async (c) => {
1582
+ if (!clientId || !clientSecret || !stateSecret) {
1583
+ return c.json({ error: "github oauth not configured" }, 503);
1584
+ }
1585
+ const err = c.req.query("error");
1586
+ if (err) return c.redirect(`${webOrigin}/settings?github_author=denied`, 302);
1587
+
1588
+ const code = c.req.query("code");
1589
+ const state = c.req.query("state") ?? "";
1590
+ if (!code) return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
1591
+
1592
+ const decoded = verifyAuthorState(state, stateSecret);
1593
+ if (!decoded) return c.json({ error: "invalid state" }, 400);
1594
+
1595
+ let token: string;
1596
+ try {
1597
+ token = await exchangeGithubOAuthCode({ clientId, clientSecret, code, redirectUrl });
1598
+ } catch (e) {
1599
+ log.error({ err: e, org_id: decoded.orgId }, "github author oauth exchange failed");
1600
+ return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
1601
+ }
1602
+
1603
+ let author: CommitAuthor | null = null;
1604
+ let installations: GithubUserInstallation[];
1605
+ let reposByInstallation: Map<number, StoredRepo[]>;
1606
+ try {
1607
+ [author, installations] =
1608
+ decoded.purpose === "author"
1609
+ ? await Promise.all([fetchCommitAuthor(token), listUserAccessibleInstallations(token)])
1610
+ : [null, await listUserAccessibleInstallations(token)];
1611
+ reposByInstallation = new Map(
1612
+ await Promise.all(
1613
+ installations.map(
1614
+ async (installation) =>
1615
+ [installation.id, await listUserAccessibleRepos(token, installation.id)] as const,
1616
+ ),
1617
+ ),
1618
+ );
1619
+ } catch (e) {
1620
+ log.error({ err: e, org_id: decoded.orgId }, "github author fetch failed");
1621
+ return c.redirect(`${webOrigin}/settings?github_author=error`, 302);
1622
+ }
1623
+
1624
+ if (installations.length === 0) {
1625
+ log.info(
1626
+ { org_id: decoded.orgId, user_id: decoded.userId, github_login: author?.githubLogin },
1627
+ "github oauth connected but no accessible app installation found",
1628
+ );
1629
+ const param = decoded.purpose === "author" ? "github_author" : "github";
1630
+ return c.redirect(`${webOrigin}/settings?${param}=no_install`, 302);
1631
+ }
1632
+
1633
+ const upserted = await db.transaction(async (tx) => {
1634
+ const rows: Array<{ id: string }> = [];
1635
+ for (const installation of installations) {
1636
+ const repos = reposByInstallation.get(installation.id) ?? [];
1637
+ const inserted = await tx
1638
+ .insert(schema.githubInstallations)
1639
+ .values({
1640
+ orgId: decoded.orgId,
1641
+ projectId: decoded.projectId,
1642
+ installationId: installation.id,
1643
+ accountLogin: installation.account?.login ?? null,
1644
+ accountType: installation.account?.type ?? null,
1645
+ repos,
1646
+ ...(author
1647
+ ? {
1648
+ commitAuthorName: author.name,
1649
+ commitAuthorEmail: author.email,
1650
+ commitAuthorGithubLogin: author.githubLogin,
1651
+ commitAuthorGithubId: author.githubId,
1652
+ commitAuthorAvatarUrl: author.avatarUrl,
1653
+ commitAuthorSetByUserId: decoded.userId,
1654
+ commitAuthorSetAt: new Date(),
1655
+ }
1656
+ : {}),
1657
+ revokedAt: null,
1658
+ })
1659
+ .onConflictDoUpdate({
1660
+ target: [
1661
+ schema.githubInstallations.projectId,
1662
+ schema.githubInstallations.installationId,
1663
+ ],
1664
+ targetWhere: sql`project_id IS NOT NULL AND revoked_at IS NULL`,
1665
+ set: {
1666
+ orgId: decoded.orgId,
1667
+ accountLogin: installation.account?.login ?? null,
1668
+ accountType: installation.account?.type ?? null,
1669
+ repos,
1670
+ ...(author
1671
+ ? {
1672
+ commitAuthorName: author.name,
1673
+ commitAuthorEmail: author.email,
1674
+ commitAuthorGithubLogin: author.githubLogin,
1675
+ commitAuthorGithubId: author.githubId,
1676
+ commitAuthorAvatarUrl: author.avatarUrl,
1677
+ commitAuthorSetByUserId: decoded.userId,
1678
+ commitAuthorSetAt: new Date(),
1679
+ }
1680
+ : {}),
1681
+ revokedAt: null,
1682
+ },
1683
+ })
1684
+ .returning({ id: schema.githubInstallations.id });
1685
+ rows.push(...inserted);
1686
+ }
1687
+ if (author) {
1688
+ await tx
1689
+ .update(schema.githubInstallations)
1690
+ .set({
1691
+ commitAuthorName: author.name,
1692
+ commitAuthorEmail: author.email,
1693
+ commitAuthorGithubLogin: author.githubLogin,
1694
+ commitAuthorGithubId: author.githubId,
1695
+ commitAuthorAvatarUrl: author.avatarUrl,
1696
+ commitAuthorSetByUserId: decoded.userId,
1697
+ commitAuthorSetAt: new Date(),
1698
+ })
1699
+ .where(
1700
+ and(
1701
+ eq(schema.githubInstallations.projectId, decoded.projectId),
1702
+ isNull(schema.githubInstallations.revokedAt),
1703
+ ),
1704
+ );
1705
+ }
1706
+ return rows;
1707
+ });
1708
+
1709
+ log.info(
1710
+ {
1711
+ org_id: decoded.orgId,
1712
+ user_id: decoded.userId,
1713
+ github_login: author?.githubLogin,
1714
+ purpose: decoded.purpose,
1715
+ installation_ids: installations.map((installation) => installation.id),
1716
+ installation_row_ids: upserted.map((row) => row.id),
1717
+ },
1718
+ "github installations reconciled from user oauth",
1719
+ );
1720
+ void syncLoopsContactsForOrg({ orgId: decoded.orgId, appUrl: webOrigin }).catch((err) => {
1721
+ log.warn({ err, org_id: decoded.orgId }, "loops contact sync failed after github connect");
1722
+ });
1723
+ if (decoded.purpose === "author") {
1724
+ return c.redirect(`${webOrigin}/settings?github_author=connected`, 302);
1725
+ }
1726
+ // access flow: send back to root so the gate can pick up and show onboarding
1727
+ return c.redirect(`${webOrigin}/`, 302);
1728
+ });
1729
+ }
1730
+
1731
+ async function exchangeGithubOAuthCode(opts: {
1732
+ clientId: string;
1733
+ clientSecret: string;
1734
+ code: string;
1735
+ redirectUrl: string;
1736
+ }): Promise<string> {
1737
+ const res = await fetch("https://github.com/login/oauth/access_token", {
1738
+ method: "POST",
1739
+ headers: {
1740
+ accept: "application/json",
1741
+ "content-type": "application/json",
1742
+ "user-agent": "superlog-api",
1743
+ },
1744
+ body: JSON.stringify({
1745
+ client_id: opts.clientId,
1746
+ client_secret: opts.clientSecret,
1747
+ code: opts.code,
1748
+ redirect_uri: opts.redirectUrl,
1749
+ }),
1750
+ });
1751
+ const data = (await res.json()) as GithubOAuthTokenResponse;
1752
+ if (!res.ok || !data.access_token) {
1753
+ throw new Error(data.error_description ?? data.error ?? `github oauth failed: ${res.status}`);
1754
+ }
1755
+ return data.access_token;
1756
+ }
1757
+
1758
+ async function githubUserRequest<T>(pathname: string, token: string): Promise<T> {
1759
+ const res = await fetch(`https://api.github.com${pathname}`, {
1760
+ headers: {
1761
+ accept: "application/vnd.github+json",
1762
+ authorization: `Bearer ${token}`,
1763
+ "x-github-api-version": "2022-11-28",
1764
+ "user-agent": "superlog-api",
1765
+ },
1766
+ });
1767
+ if (!res.ok) {
1768
+ const text = await res.text().catch(() => "");
1769
+ throw new Error(`github GET ${pathname} failed: ${res.status} ${text}`);
1770
+ }
1771
+ return (await res.json()) as T;
1772
+ }
1773
+
1774
+ async function fetchCommitAuthor(token: string): Promise<CommitAuthor> {
1775
+ const viewer = await githubUserRequest<GithubViewerResponse>("/user", token);
1776
+ let emails: GithubEmailResponse[] = [];
1777
+ try {
1778
+ emails = await githubUserRequest<GithubEmailResponse[]>("/user/emails", token);
1779
+ } catch (err) {
1780
+ log.warn(
1781
+ { err, github_login: viewer.login },
1782
+ "github user emails unavailable; falling back to profile or noreply email",
1783
+ );
1784
+ }
1785
+ const selectedEmail =
1786
+ emails.find((email) => email.primary && email.verified)?.email ??
1787
+ emails.find((email) => email.verified)?.email ??
1788
+ viewer.email ??
1789
+ `${viewer.id}+${viewer.login}@users.noreply.github.com`;
1790
+ const name = sanitizeGitName(viewer.name ?? viewer.login);
1791
+ const email = sanitizeGitEmail(selectedEmail);
1792
+ if (!name || !email) throw new Error("github profile did not provide a usable git identity");
1793
+ return {
1794
+ name,
1795
+ email,
1796
+ githubLogin: viewer.login,
1797
+ githubId: viewer.id,
1798
+ avatarUrl: viewer.avatar_url,
1799
+ };
1800
+ }
1801
+
1802
+ async function listUserAccessibleInstallations(token: string): Promise<GithubUserInstallation[]> {
1803
+ const installs: GithubUserInstallation[] = [];
1804
+ for (let page = 1; page <= 10; page += 1) {
1805
+ const data = await githubUserRequest<GithubUserInstallationsResponse>(
1806
+ `/user/installations?per_page=100&page=${page}`,
1807
+ token,
1808
+ );
1809
+ installs.push(...data.installations);
1810
+ if (data.installations.length < 100) break;
1811
+ }
1812
+
1813
+ return installs.filter((installation) => !installation.suspended_at);
1814
+ }
1815
+
1816
+ function githubInstallationManageUrl(installation: {
1817
+ installationId: number;
1818
+ accountLogin: string | null;
1819
+ accountType: string | null;
1820
+ }): string {
1821
+ if (installation.accountType === "Organization" && installation.accountLogin) {
1822
+ return `https://github.com/organizations/${installation.accountLogin}/settings/installations/${installation.installationId}`;
1823
+ }
1824
+ return `https://github.com/settings/installations/${installation.installationId}`;
1825
+ }
1826
+
1827
+ function isMissingGithubInstallation(err: unknown): boolean {
1828
+ return err instanceof Error && err.message.includes("failed: 404");
1829
+ }
1830
+
1831
+ function normalizeRepoAccess(value: unknown): RepoAccess {
1832
+ if (!value || typeof value !== "object") return {};
1833
+ const disabledRepoIds = (value as RepoAccess).disabledRepoIds;
1834
+ if (!Array.isArray(disabledRepoIds)) return {};
1835
+ return {
1836
+ disabledRepoIds: [
1837
+ ...new Set(disabledRepoIds.filter((id): id is number => Number.isFinite(id) && id > 0)),
1838
+ ],
1839
+ };
1840
+ }
1841
+
1842
+ function isRepoEnabled(repoAccess: RepoAccess, repoId: number): boolean {
1843
+ return !(repoAccess.disabledRepoIds ?? []).includes(repoId);
1844
+ }
1845
+
1846
+ function setRepoEnabled(value: unknown, repoId: number, enabled: boolean): RepoAccess {
1847
+ const repoAccess = normalizeRepoAccess(value);
1848
+ const disabled = new Set(repoAccess.disabledRepoIds ?? []);
1849
+ if (enabled) {
1850
+ disabled.delete(repoId);
1851
+ } else {
1852
+ disabled.add(repoId);
1853
+ }
1854
+ return { disabledRepoIds: [...disabled].sort((a, b) => a - b) };
1855
+ }
1856
+
1857
+ function dedupeRepoSummaries(repos: GithubRepoSummary[]): GithubRepoSummary[] {
1858
+ const seen = new Set<string>();
1859
+ const deduped: GithubRepoSummary[] = [];
1860
+ for (const repo of repos) {
1861
+ const existing = deduped.find((item) => item.fullName === repo.fullName);
1862
+ if (existing) {
1863
+ existing.enabled = existing.enabled || repo.enabled;
1864
+ continue;
1865
+ }
1866
+ if (!seen.has(repo.fullName)) {
1867
+ seen.add(repo.fullName);
1868
+ deduped.push({ ...repo });
1869
+ }
1870
+ }
1871
+ return deduped;
1872
+ }
1873
+
1874
+ async function listUserAccessibleRepos(
1875
+ token: string,
1876
+ installationId: number,
1877
+ ): Promise<StoredRepo[]> {
1878
+ const repos: StoredRepo[] = [];
1879
+ for (let page = 1; page <= 10; page += 1) {
1880
+ const data = await githubUserRequest<GithubUserInstallationReposResponse>(
1881
+ `/user/installations/${installationId}/repositories?per_page=100&page=${page}`,
1882
+ token,
1883
+ );
1884
+ repos.push(...data.repositories.map(toStoredRepo));
1885
+ if (data.repositories.length < 100) break;
1886
+ }
1887
+ return repos;
1888
+ }
1889
+
1890
+ function sanitizeGitName(value: string): string | null {
1891
+ const cleaned = value
1892
+ .replace(/[\r\n<>]/g, " ")
1893
+ .replace(/\s+/g, " ")
1894
+ .trim();
1895
+ return cleaned || null;
1896
+ }
1897
+
1898
+ function sanitizeGitEmail(value: string): string | null {
1899
+ const cleaned = value.trim();
1900
+ if (!cleaned || /[\r\n<>]/.test(cleaned) || !cleaned.includes("@")) return null;
1901
+ return cleaned;
1902
+ }
1903
+
1904
+ async function resolveUserOrg(
1905
+ c: Context<{ Variables: Vars }>,
1906
+ ): Promise<{ userId: string; orgId: string; projectId: string } | null> {
1907
+ const userId = c.var.userId as string | undefined;
1908
+ if (!userId) return null;
1909
+ const ctx = await resolveActiveOrgContext({
1910
+ userId,
1911
+ preferredOrgId: c.var.orgId as string | null | undefined,
1912
+ }).catch(() => null);
1913
+ if (!ctx) return null;
1914
+ return { userId: ctx.user.id, orgId: ctx.org.id, projectId: ctx.project.id };
1915
+ }
1916
+
1917
+ type AuthorStatePayload = {
1918
+ orgId: string;
1919
+ projectId: string;
1920
+ userId: string;
1921
+ purpose: "access" | "author";
1922
+ };
1923
+
1924
+ function signAuthorState(p: AuthorStatePayload, secret: string): string {
1925
+ const body = `${p.orgId}.${p.projectId}.${p.userId}.${p.purpose}.${Date.now()}`;
1926
+ const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
1927
+ return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`;
1928
+ }
1929
+
1930
+ function verifyAuthorState(state: string, secret: string): AuthorStatePayload | null {
1931
+ const [payloadB64, sig] = state.split(".");
1932
+ if (!payloadB64 || !sig) return null;
1933
+ const body = Buffer.from(payloadB64, "base64url").toString("utf8");
1934
+ const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
1935
+ const provided = Buffer.from(sig);
1936
+ const expectedBuf = Buffer.from(expected);
1937
+ if (provided.length !== expectedBuf.length) return null;
1938
+ if (!crypto.timingSafeEqual(provided, expectedBuf)) return null;
1939
+ // Current format is `${orgId}.${projectId}.${userId}.${purpose}.${ts}` (5
1940
+ // parts). Old 3- or 4-part states (no projectId) are rejected — 10-min TTL
1941
+ // means worst case is users mid-flow during deploy clicking again.
1942
+ const parts = body.split(".");
1943
+ if (parts.length !== 5) return null;
1944
+ const [orgId, projectId, userId, purpose, tsRaw] = parts as [
1945
+ string,
1946
+ string,
1947
+ string,
1948
+ string,
1949
+ string,
1950
+ ];
1951
+ if (!orgId || !projectId || !userId || !tsRaw) return null;
1952
+ if (purpose !== "access" && purpose !== "author") return null;
1953
+ const ts = Number(tsRaw);
1954
+ if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null;
1955
+ return { orgId, projectId, userId, purpose };
1956
+ }
1957
+
1958
+ type StateKind = "cli" | "web";
1959
+
1960
+ function signState(kind: StateKind, value: string, secret: string): string {
1961
+ const payload = `${kind}.${value}.${Date.now()}`;
1962
+ const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
1963
+ return `${base64url(payload)}.${sig}`;
1964
+ }
1965
+
1966
+ function verifyState(state: string, secret: string): { kind: StateKind; value: string } | null {
1967
+ const [payloadB64, sig] = state.split(".");
1968
+ if (!payloadB64 || !sig) return null;
1969
+ const payload = Buffer.from(payloadB64, "base64url").toString("utf8");
1970
+ const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
1971
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
1972
+ const [kind, value, tsRaw] = payload.split(".");
1973
+ if ((kind !== "cli" && kind !== "web") || !value || !tsRaw) return null;
1974
+ const ts = Number(tsRaw);
1975
+ // Match device code TTL: 10 min window.
1976
+ if (!Number.isFinite(ts) || Date.now() - ts > 10 * 60 * 1000) return null;
1977
+ return { kind, value };
1978
+ }
1979
+
1980
+ // Management-API install state. Used when a platform backend mints an install
1981
+ // URL via /api/v1/...; the state encodes scope (org vs project) and an
1982
+ // optional return URL the user is bounced to after install. 30-min TTL since
1983
+ // the customer's operator might not click immediately.
1984
+ const MGMT_STATE_PREFIX = "mgmt-v1";
1985
+ export type GithubMgmtState =
1986
+ | { scope: "org"; orgId: string; returnUrl: string | null }
1987
+ | { scope: "project"; projectId: string; returnUrl: string | null };
1988
+
1989
+ function signMgmtState(payload: GithubMgmtState, secret: string): string {
1990
+ const body = `${MGMT_STATE_PREFIX}.${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}.${Date.now()}`;
1991
+ const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
1992
+ return `${Buffer.from(body, "utf8").toString("base64url")}.${sig}`;
1993
+ }
1994
+
1995
+ function verifyMgmtState(state: string, secret: string): GithubMgmtState | null {
1996
+ const [payloadB64, sig] = state.split(".");
1997
+ if (!payloadB64 || !sig) return null;
1998
+ const body = Buffer.from(payloadB64, "base64url").toString("utf8");
1999
+ const expected = crypto.createHmac("sha256", secret).update(body).digest("base64url");
2000
+ const provided = Buffer.from(sig);
2001
+ const expectedBuf = Buffer.from(expected);
2002
+ if (provided.length !== expectedBuf.length) return null;
2003
+ if (!crypto.timingSafeEqual(provided, expectedBuf)) return null;
2004
+ const parts = body.split(".");
2005
+ if (parts.length !== 3 || parts[0] !== MGMT_STATE_PREFIX) return null;
2006
+ const ts = Number(parts[2]);
2007
+ if (!Number.isFinite(ts) || Date.now() - ts > 30 * 60 * 1000) return null;
2008
+ try {
2009
+ const json = Buffer.from(parts[1] as string, "base64url").toString("utf8");
2010
+ const parsed = JSON.parse(json) as Partial<GithubMgmtState>;
2011
+ if (parsed.scope === "org" && typeof parsed.orgId === "string") {
2012
+ return { scope: "org", orgId: parsed.orgId, returnUrl: parsed.returnUrl ?? null };
2013
+ }
2014
+ if (parsed.scope === "project" && typeof parsed.projectId === "string") {
2015
+ return { scope: "project", projectId: parsed.projectId, returnUrl: parsed.returnUrl ?? null };
2016
+ }
2017
+ return null;
2018
+ } catch {
2019
+ return null;
2020
+ }
2021
+ }
2022
+
2023
+ // Public helper used by the management API to mint install URLs without
2024
+ // touching state internals.
2025
+ export function buildGithubMgmtInstallUrl(args: GithubMgmtState): { url: string } | null {
2026
+ const appSlug = process.env.GITHUB_APP_SLUG;
2027
+ const stateSecret = process.env.STATE_SIGNING_SECRET;
2028
+ if (!appSlug || !stateSecret) return null;
2029
+ const state = signMgmtState(args, stateSecret);
2030
+ const url = new URL(`https://github.com/apps/${appSlug}/installations/new`);
2031
+ url.searchParams.set("state", state);
2032
+ return { url: url.toString() };
2033
+ }
2034
+
2035
+ function base64url(s: string): string {
2036
+ return Buffer.from(s, "utf8").toString("base64url");
2037
+ }
superlog/apps/api/src/http-observability.test.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { buildApiSpanAttributes, normalizeHttpRoute } from "./http-observability.js";
4
+
5
+ test("normalizeHttpRoute collapses dynamic ids while keeping useful endpoint names", () => {
6
+ assert.equal(
7
+ normalizeHttpRoute("/api/projects/018f34d2-9f91-76cc-9e13-34f3287ff03d/explore/traces"),
8
+ "/api/projects/:id/explore/traces",
9
+ );
10
+ assert.equal(
11
+ normalizeHttpRoute("/api/incidents/inc_01jwj3c9j7q93w2p50ab4t72w3/events"),
12
+ "/api/incidents/:id/events",
13
+ );
14
+ assert.equal(normalizeHttpRoute("/api/me?tab=settings"), "/api/me");
15
+ });
16
+
17
+ test("buildApiSpanAttributes adds tenant and normalized endpoint attributes for server spans", () => {
18
+ assert.deepEqual(
19
+ buildApiSpanAttributes({
20
+ method: "post",
21
+ path: "/api/projects/project_123/explore/logs?limit=50",
22
+ routePath: "/api/*",
23
+ statusCode: 201,
24
+ orgId: "org_123",
25
+ userId: "user_123",
26
+ }),
27
+ {
28
+ "http.request.method": "POST",
29
+ "http.route": "/api/projects/:id/explore/logs",
30
+ "superlog.endpoint": "/api/projects/:id/explore/logs",
31
+ "http.response.status_code": 201,
32
+ "http.response.status_class": "2xx",
33
+ "tenant.org.id": "org_123",
34
+ "enduser.id": "user_123",
35
+ "tenant.project.id": "project_123",
36
+ },
37
+ );
38
+ });
39
+
40
+ test("buildApiSpanAttributes extracts project ids from all project-scoped API route families", () => {
41
+ for (const path of [
42
+ "/api/projects/project_123/explore/logs",
43
+ "/api/org/projects/project_123/keys",
44
+ "/api/v1/projects/project_123/logs",
45
+ ]) {
46
+ assert.equal(
47
+ buildApiSpanAttributes({
48
+ method: "get",
49
+ path,
50
+ statusCode: 200,
51
+ })["tenant.project.id"],
52
+ "project_123",
53
+ );
54
+ }
55
+ });
superlog/apps/api/src/http-observability.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { trace } from "@opentelemetry/api";
2
+ import type { Context, Next } from "hono";
3
+ import { HTTPException } from "hono/http-exception";
4
+
5
+ type MetricAttributes = Record<string, string | number | boolean>;
6
+
7
+ export type ApiSpanAttributeInput = {
8
+ method: string;
9
+ path: string;
10
+ routePath?: string;
11
+ statusCode: number;
12
+ orgId?: string | null;
13
+ userId?: string | null;
14
+ };
15
+
16
+ function statusClass(statusCode: number): string {
17
+ return `${Math.floor(statusCode / 100)}xx`;
18
+ }
19
+
20
+ function isDynamicSegment(segment: string): boolean {
21
+ if (/^\d+$/.test(segment)) return true;
22
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
23
+ return true;
24
+ }
25
+ if (/^(project|org|user|inc|sui|key)_[a-z0-9]+$/i.test(segment)) return true;
26
+ if (/^[a-z][a-z0-9]*_[a-z0-9]{8,}$/i.test(segment)) return true;
27
+ return segment.length >= 20 && /[0-9]/.test(segment) && /^[a-z0-9_-]+$/i.test(segment);
28
+ }
29
+
30
+ export function normalizeHttpRoute(rawPath: string): string {
31
+ const path = rawPath.split("?")[0] || "/";
32
+ return path
33
+ .split("/")
34
+ .map((segment) => {
35
+ if (!segment) return segment;
36
+ if (segment.startsWith(":")) return segment;
37
+ return isDynamicSegment(segment) ? ":id" : segment;
38
+ })
39
+ .join("/");
40
+ }
41
+
42
+ function routeName(input: Pick<ApiSpanAttributeInput, "path" | "routePath">): string {
43
+ return input.routePath && input.routePath !== "*" && input.routePath !== "/api/*"
44
+ ? input.routePath
45
+ : normalizeHttpRoute(input.path);
46
+ }
47
+
48
+ function extractProjectId(path: string): string | null {
49
+ const match = path.match(/^\/api\/(?:org\/)?(?:v1\/)?projects\/([^/?#]+)/);
50
+ return match?.[1] ?? null;
51
+ }
52
+
53
+ export function buildApiSpanAttributes(input: ApiSpanAttributeInput): MetricAttributes {
54
+ const endpoint = routeName(input);
55
+ const attrs: MetricAttributes = {
56
+ "http.request.method": input.method.toUpperCase(),
57
+ "http.route": endpoint,
58
+ "superlog.endpoint": endpoint,
59
+ "http.response.status_code": input.statusCode,
60
+ "http.response.status_class": statusClass(input.statusCode),
61
+ };
62
+ if (input.orgId) attrs["tenant.org.id"] = input.orgId;
63
+ if (input.userId) attrs["enduser.id"] = input.userId;
64
+ const projectId = extractProjectId(input.path);
65
+ if (projectId) attrs["tenant.project.id"] = projectId;
66
+ return attrs;
67
+ }
68
+
69
+ type ApiObservabilityVars = {
70
+ userId?: string;
71
+ orgId?: string | null;
72
+ };
73
+
74
+ export function createApiHttpObservabilityMiddleware(): (
75
+ c: Context<{ Variables: ApiObservabilityVars }>,
76
+ next: Next,
77
+ ) => Promise<void> {
78
+ return async (c, next) => {
79
+ let statusCode = 500;
80
+
81
+ try {
82
+ await next();
83
+ statusCode = c.res.status;
84
+ } catch (err) {
85
+ statusCode = err instanceof HTTPException ? err.status : 500;
86
+ throw err;
87
+ } finally {
88
+ trace.getActiveSpan()?.setAttributes(
89
+ buildApiSpanAttributes({
90
+ method: c.req.method,
91
+ path: c.req.path,
92
+ routePath: c.req.routePath,
93
+ statusCode,
94
+ orgId: c.var.orgId,
95
+ userId: c.var.userId,
96
+ }),
97
+ );
98
+ }
99
+ };
100
+ }
superlog/apps/api/src/impersonation.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db, schema } from "@superlog/db";
2
+ import { asc, eq, ilike, or } from "drizzle-orm";
3
+ import type { Hono } from "hono";
4
+ import { requireStaff } from "./staff.js";
5
+
6
+ type Vars = { userId: string; orgId: string | null };
7
+ const MAX_TARGET_ROWS = 100;
8
+
9
+ function normalizeSearch(raw: string | null): string {
10
+ return (raw ?? "").trim().replace(/[%_]/g, "").slice(0, 80);
11
+ }
12
+
13
+ // Minimal staff-only user picker for the impersonation command-palette flow.
14
+ // The actual impersonation is performed by Better Auth's admin plugin
15
+ // (authClient.admin.impersonateUser); this endpoint only supplies the list of
16
+ // candidate users + the orgs they belong to so the palette can search them.
17
+ // It deliberately exposes nothing beyond what a user picker needs — no usage
18
+ // metrics or per-org analytics.
19
+ export function mountImpersonation(app: Hono<{ Variables: Vars }>): void {
20
+ app.get("/api/admin/impersonation-targets", async (c) => {
21
+ await requireStaff(c.var.userId);
22
+ const q = normalizeSearch(c.req.query("q") ?? null);
23
+ const rowsQuery = db
24
+ .select({
25
+ userId: schema.users.id,
26
+ email: schema.users.email,
27
+ name: schema.users.name,
28
+ orgName: schema.orgs.name,
29
+ orgSlug: schema.orgs.slug,
30
+ })
31
+ .from(schema.orgMembers)
32
+ .innerJoin(schema.users, eq(schema.users.id, schema.orgMembers.userId))
33
+ .innerJoin(schema.orgs, eq(schema.orgs.id, schema.orgMembers.orgId))
34
+ .orderBy(asc(schema.users.email), asc(schema.orgs.name))
35
+ .limit(MAX_TARGET_ROWS);
36
+ const rows = q
37
+ ? await rowsQuery.where(
38
+ or(
39
+ ilike(schema.users.email, `%${q}%`),
40
+ ilike(schema.users.name, `%${q}%`),
41
+ ilike(schema.orgs.name, `%${q}%`),
42
+ ilike(schema.orgs.slug, `%${q}%`),
43
+ ),
44
+ )
45
+ : await rowsQuery;
46
+
47
+ // Collapse to one row per user; carry the orgs they belong to so the
48
+ // palette can match on either a person or a company.
49
+ const byUser = new Map<
50
+ string,
51
+ { userId: string; email: string; name: string | null; orgs: { name: string; slug: string }[] }
52
+ >();
53
+ for (const r of rows) {
54
+ const existing = byUser.get(r.userId);
55
+ if (existing) {
56
+ existing.orgs.push({ name: r.orgName, slug: r.orgSlug });
57
+ } else {
58
+ byUser.set(r.userId, {
59
+ userId: r.userId,
60
+ email: r.email,
61
+ name: r.name,
62
+ orgs: [{ name: r.orgName, slug: r.orgSlug }],
63
+ });
64
+ }
65
+ }
66
+ return c.json({ users: Array.from(byUser.values()), limit: MAX_TARGET_ROWS });
67
+ });
68
+ }
superlog/apps/api/src/incidents/detail.test.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import type { Issue, IssueSample } from "@superlog/db";
4
+
5
+ // detail.ts transitively imports the db client, which throws at import time
6
+ // without a connection string. Set a dummy URL before the dynamic import (the
7
+ // porsager client connects lazily, so these pure-function tests never open a
8
+ // socket). Same dynamic-import pattern as linear.test.ts / loops.test.ts.
9
+ process.env.DATABASE_URL ??= "postgres://localhost:5434/superlog";
10
+ const { toIssueContext } = await import("./detail.js");
11
+
12
+ test("issue context carries the stored telemetry sample as-is", () => {
13
+ const sample: IssueSample = {
14
+ kind: "span",
15
+ service: "api",
16
+ severity: "ERROR",
17
+ message: "boom",
18
+ body: null,
19
+ exceptionType: "TypeError",
20
+ topFrame: "checkout.ts:42",
21
+ normalizedFrames: ["checkout.ts:42", "handler.ts:10"],
22
+ stacktrace: "TypeError: boom\n at checkout.ts:42",
23
+ seenAt: "2026-05-24T01:00:00.000Z",
24
+ traceId: "abc123",
25
+ spanId: "def456",
26
+ spanName: "POST /checkout",
27
+ spanAttrs: { "http.route": "/checkout" },
28
+ };
29
+ const ctx = toIssueContext({ ...baseIssue(), lastSample: sample });
30
+
31
+ assert.equal(ctx.id, "issue-1");
32
+ assert.equal(ctx.kind, "span");
33
+ assert.equal(ctx.exceptionType, "TypeError");
34
+ assert.equal(ctx.eventCount, 12);
35
+ assert.equal(ctx.firstSeen, "2026-05-24T00:00:00.000Z");
36
+ assert.equal(ctx.lastSeen, "2026-05-24T01:00:00.000Z");
37
+ // The full log/trace shape is passed through untouched so the agent can walk
38
+ // to live telemetry via the trace_id / span_id.
39
+ assert.deepEqual(ctx.sample, sample);
40
+ assert.equal(ctx.sample?.traceId, "abc123");
41
+ });
42
+
43
+ test("issue context tolerates issues that never captured a sample", () => {
44
+ const ctx = toIssueContext({ ...baseIssue(), lastSample: null });
45
+ assert.equal(ctx.sample, null);
46
+ });
47
+
48
+ function baseIssue(): Issue {
49
+ return {
50
+ id: "issue-1",
51
+ projectId: "project-1",
52
+ fingerprint: "fp-1",
53
+ kind: "span",
54
+ service: "api",
55
+ exceptionType: "TypeError",
56
+ title: "TypeError in checkout",
57
+ message: "boom",
58
+ topFrame: "checkout.ts:42",
59
+ normalizedFrames: ["checkout.ts:42"],
60
+ lastSample: null,
61
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
62
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
63
+ silencedAt: null,
64
+ lastAlertedAt: null,
65
+ slackMessageTs: null,
66
+ eventCount: 12,
67
+ groupingState: "grouped",
68
+ groupingSource: null,
69
+ groupingReason: null,
70
+ groupingAttemptedAt: null,
71
+ groupingAttemptCount: 0,
72
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
73
+ };
74
+ }
superlog/apps/api/src/incidents/detail.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type AgentRun, type Issue, type IssueSample, db, schema } from "@superlog/db";
2
+ import { desc, eq, inArray } from "drizzle-orm";
3
+ import { type IncidentSummary, toIncidentSummary } from "./search.js";
4
+
5
+ export type IssueContext = {
6
+ id: string;
7
+ kind: string;
8
+ service: string | null;
9
+ exceptionType: string;
10
+ title: string;
11
+ message: string | null;
12
+ topFrame: string | null;
13
+ fingerprint: string;
14
+ eventCount: number;
15
+ firstSeen: string;
16
+ lastSeen: string;
17
+ /**
18
+ * The representative log/trace shape captured for this issue: trace_id /
19
+ * span_id, stacktrace, and span/log/resource attributes. This is the inline
20
+ * "issue context" — the agent can read it directly, then walk to live
21
+ * telemetry with query_traces / query_logs using the trace_id.
22
+ */
23
+ sample: IssueSample | null;
24
+ };
25
+
26
+ /** Compact, agent-friendly projection of an issue plus its stored telemetry sample. */
27
+ export function toIssueContext(issue: Issue): IssueContext {
28
+ return {
29
+ id: issue.id,
30
+ kind: issue.kind,
31
+ service: issue.service,
32
+ exceptionType: issue.exceptionType,
33
+ title: issue.title,
34
+ message: issue.message,
35
+ topFrame: issue.topFrame,
36
+ fingerprint: issue.fingerprint,
37
+ eventCount: issue.eventCount,
38
+ firstSeen: issue.firstSeen.toISOString(),
39
+ lastSeen: issue.lastSeen.toISOString(),
40
+ sample: issue.lastSample ?? null,
41
+ };
42
+ }
43
+
44
+ export type AgentRunPointer = {
45
+ id: string;
46
+ state: string;
47
+ runtime: string;
48
+ selectedRepoFullName: string | null;
49
+ failureReason: string | null;
50
+ startedAt: string | null;
51
+ completedAt: string | null;
52
+ };
53
+
54
+ function toAgentRunPointer(run: AgentRun): AgentRunPointer {
55
+ return {
56
+ id: run.id,
57
+ state: run.state,
58
+ runtime: run.runtime,
59
+ selectedRepoFullName: run.selectedRepoFullName,
60
+ failureReason: run.failureReason,
61
+ startedAt: run.startedAt ? run.startedAt.toISOString() : null,
62
+ completedAt: run.completedAt ? run.completedAt.toISOString() : null,
63
+ };
64
+ }
65
+
66
+ export type IncidentDetail = {
67
+ incident: IncidentSummary;
68
+ issues: IssueContext[];
69
+ latestAgentRun: AgentRunPointer | null;
70
+ };
71
+
72
+ /**
73
+ * Load an incident by its (global) id and assemble the full detail an agent
74
+ * needs to answer "what's going on in this incident": the incident summary
75
+ * (with project_id for follow-up telemetry queries), every linked issue with
76
+ * its stored log/trace sample, and a pointer to the latest investigation.
77
+ *
78
+ * Returns null when the incident does not exist. Access control is the
79
+ * caller's responsibility — resolve `incident.projectId` against the session.
80
+ */
81
+ export async function getIncidentDetail(incidentId: string): Promise<IncidentDetail | null> {
82
+ const incident = await db.query.incidents.findFirst({
83
+ where: eq(schema.incidents.id, incidentId),
84
+ });
85
+ if (!incident) return null;
86
+
87
+ const [links, agentRuns] = await Promise.all([
88
+ db.query.incidentIssues.findMany({
89
+ where: eq(schema.incidentIssues.incidentId, incident.id),
90
+ orderBy: [desc(schema.incidentIssues.createdAt)],
91
+ }),
92
+ db.query.agentRuns.findMany({
93
+ where: eq(schema.agentRuns.incidentId, incident.id),
94
+ orderBy: [desc(schema.agentRuns.createdAt)],
95
+ limit: 1,
96
+ }),
97
+ ]);
98
+
99
+ const issues =
100
+ links.length > 0
101
+ ? await db.query.issues.findMany({
102
+ where: inArray(
103
+ schema.issues.id,
104
+ links.map((link) => link.issueId),
105
+ ),
106
+ orderBy: [desc(schema.issues.lastSeen)],
107
+ })
108
+ : [];
109
+
110
+ return {
111
+ incident: toIncidentSummary(incident),
112
+ issues: issues.map(toIssueContext),
113
+ latestAgentRun: agentRuns[0] ? toAgentRunPointer(agentRuns[0]) : null,
114
+ };
115
+ }
superlog/apps/api/src/incidents/list.test.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ type IncidentListItemInput,
5
+ buildIncidentListItem,
6
+ shouldInlineIncidentListStats,
7
+ } from "./list.js";
8
+
9
+ test("incident list omits graph stats by default so slow telemetry cannot block the page", () => {
10
+ const row = buildIncidentListItem(baseInput());
11
+
12
+ assert.equal("buckets" in row, false);
13
+ assert.equal("impactedUsers" in row, false);
14
+ assert.equal("impactedUsersAvailable" in row, false);
15
+ assert.equal("impactedUsersCapped" in row, false);
16
+ assert.equal("windowDays" in row, false);
17
+ });
18
+
19
+ test("incident list can still inline graph stats when explicitly requested", () => {
20
+ const row = buildIncidentListItem({
21
+ ...baseInput(),
22
+ stats: {
23
+ windowDays: 14,
24
+ buckets: [{ day: "2026-05-24", count: 4 }],
25
+ impactedUsers: 3,
26
+ impactedUsersAvailable: true,
27
+ impactedUsersCapped: false,
28
+ },
29
+ });
30
+
31
+ assert.deepEqual(row.buckets, [{ day: "2026-05-24", count: 4 }]);
32
+ assert.equal(row.windowDays, 14);
33
+ assert.equal(row.impactedUsers, 3);
34
+ assert.equal(row.impactedUsersAvailable, true);
35
+ assert.equal(row.impactedUsersCapped, false);
36
+ });
37
+
38
+ test("incident list only inlines graph stats behind an explicit query flag", () => {
39
+ assert.equal(shouldInlineIncidentListStats(undefined), false);
40
+ assert.equal(shouldInlineIncidentListStats(""), false);
41
+ assert.equal(shouldInlineIncidentListStats("0"), false);
42
+ assert.equal(shouldInlineIncidentListStats("true"), true);
43
+ assert.equal(shouldInlineIncidentListStats("1"), true);
44
+ });
45
+
46
+ function baseInput(): IncidentListItemInput {
47
+ return {
48
+ incident: {
49
+ id: "incident-1",
50
+ projectId: "project-1",
51
+ service: "api",
52
+ environment: "production",
53
+ title: "Checkout failures",
54
+ codename: "steady-amber",
55
+ severity: "SEV-2",
56
+ status: "open",
57
+ noiseReason: null,
58
+ noiseResolvedAt: null,
59
+ mergedAt: null,
60
+ slackInstallationId: null,
61
+ lastSlackPostedAt: null,
62
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
63
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
64
+ issueCount: 1,
65
+ slackChannelId: null,
66
+ slackThreadTs: null,
67
+ autoInvestigateSuppressedUntil: null,
68
+ autorecoveryLastEvaluatedAt: null,
69
+ agentSummary: null,
70
+ rootCauseText: null,
71
+ rootCauseConfidence: null,
72
+ estimatedImpactText: null,
73
+ estimatedImpactConfidence: null,
74
+ suggestedSeverity: null,
75
+ noiseClassification: null,
76
+ resolutionClassification: null,
77
+ findingsAgentRunId: null,
78
+ resolvedByKind: null,
79
+ resolvedByUserId: null,
80
+ resolvedBySlackUserId: null,
81
+ resolvedReasonCode: null,
82
+ resolvedReasonText: null,
83
+ resolvedAt: null,
84
+ mergedIntoId: null,
85
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
86
+ updatedAt: new Date("2026-05-24T00:00:00.000Z"),
87
+ },
88
+ agentRun: null,
89
+ pendingResolutionProposal: null,
90
+ };
91
+ }
superlog/apps/api/src/incidents/list.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentRun, Incident, IncidentResolutionProposalConfidence } from "@superlog/db";
2
+
3
+ export type IncidentListStats = {
4
+ windowDays: number;
5
+ buckets: { day: string; count: number }[];
6
+ impactedUsers: number;
7
+ impactedUsersAvailable: boolean;
8
+ impactedUsersCapped: boolean;
9
+ };
10
+
11
+ export type IncidentListPendingResolutionProposal = {
12
+ id: string;
13
+ sourceKind: string;
14
+ confidence: IncidentResolutionProposalConfidence;
15
+ proposedReasonCode: string;
16
+ proposedReasonText: string;
17
+ proposedAt: string;
18
+ };
19
+
20
+ export type IncidentListItemInput = {
21
+ incident: Incident;
22
+ agentRun: AgentRun | null;
23
+ pendingResolutionProposal: IncidentListPendingResolutionProposal | null;
24
+ stats?: IncidentListStats;
25
+ };
26
+
27
+ export type IncidentListItem = {
28
+ incident: Incident;
29
+ agentRun: AgentRun | null;
30
+ pendingResolutionProposal: IncidentListPendingResolutionProposal | null;
31
+ windowDays?: number;
32
+ buckets?: { day: string; count: number }[];
33
+ impactedUsers?: number;
34
+ impactedUsersAvailable?: boolean;
35
+ impactedUsersCapped?: boolean;
36
+ };
37
+
38
+ export function shouldInlineIncidentListStats(value: string | undefined): boolean {
39
+ return value === "1" || value === "true";
40
+ }
41
+
42
+ export function buildIncidentListItem(input: IncidentListItemInput): IncidentListItem {
43
+ const base = {
44
+ incident: input.incident,
45
+ agentRun: input.agentRun,
46
+ pendingResolutionProposal: input.pendingResolutionProposal,
47
+ };
48
+
49
+ if (!input.stats) return base;
50
+
51
+ return {
52
+ ...base,
53
+ windowDays: input.stats.windowDays,
54
+ buckets: input.stats.buckets,
55
+ impactedUsers: input.stats.impactedUsers,
56
+ impactedUsersAvailable: input.stats.impactedUsersAvailable,
57
+ impactedUsersCapped: input.stats.impactedUsersCapped,
58
+ };
59
+ }
superlog/apps/api/src/incidents/pr-retry.test.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import type { AgentRunResult } from "@superlog/db";
4
+ import { getPrDeliveryRetryEligibility } from "./pr-retry.js";
5
+
6
+ test("pr delivery retry is allowed for failed pr_open_failed runs with a pending reusable patch", () => {
7
+ const result: AgentRunResult = {
8
+ state: "failed",
9
+ summary: "Failed to validate or open the PR.",
10
+ failureReason: "pr_open_failed",
11
+ pr: {
12
+ selectedRepoFullName: "acme/api",
13
+ branchName: "superlog/fix-api",
14
+ baseBranch: "main",
15
+ patch: "diff --git a/a b/a\n",
16
+ validationPassed: true,
17
+ openStatus: "pending",
18
+ },
19
+ };
20
+
21
+ assert.deepEqual(
22
+ getPrDeliveryRetryEligibility({
23
+ state: "failed",
24
+ failureReason: "pr_open_failed",
25
+ result,
26
+ }),
27
+ { canRetry: true },
28
+ );
29
+ });
30
+
31
+ test("pr delivery retry is denied when no patch can be reused", () => {
32
+ const result: AgentRunResult = {
33
+ state: "failed",
34
+ summary: "Failed to validate or open the PR.",
35
+ failureReason: "pr_open_failed",
36
+ pr: {
37
+ selectedRepoFullName: "acme/api",
38
+ branchName: "superlog/fix-api",
39
+ baseBranch: "main",
40
+ validationPassed: true,
41
+ openStatus: "pending",
42
+ },
43
+ };
44
+
45
+ assert.deepEqual(
46
+ getPrDeliveryRetryEligibility({
47
+ state: "failed",
48
+ failureReason: "pr_open_failed",
49
+ result,
50
+ }),
51
+ { canRetry: false, reason: "agent run has no reusable patch" },
52
+ );
53
+ });
54
+
55
+ test("pr delivery retry is denied for non PR-open failures", () => {
56
+ assert.deepEqual(
57
+ getPrDeliveryRetryEligibility({
58
+ state: "failed",
59
+ failureReason: "runtime_budget_exhausted",
60
+ result: null,
61
+ }),
62
+ { canRetry: false, reason: "agent run did not fail while opening a PR" },
63
+ );
64
+ });
superlog/apps/api/src/incidents/pr-retry.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentRunResult } from "@superlog/db";
2
+
3
+ type RetryableAgentRun = {
4
+ state: string;
5
+ failureReason: string | null;
6
+ result: AgentRunResult | null;
7
+ };
8
+
9
+ export type PrDeliveryRetryEligibility = { canRetry: true } | { canRetry: false; reason: string };
10
+
11
+ export function getPrDeliveryRetryEligibility(
12
+ agentRun: RetryableAgentRun | null,
13
+ ): PrDeliveryRetryEligibility {
14
+ if (!agentRun) return { canRetry: false, reason: "agent run not found" };
15
+ if (agentRun.state !== "failed") {
16
+ return { canRetry: false, reason: "agent run is not failed" };
17
+ }
18
+ if (agentRun.failureReason !== "pr_open_failed") {
19
+ return { canRetry: false, reason: "agent run did not fail while opening a PR" };
20
+ }
21
+
22
+ const pr = agentRun.result?.pr ?? null;
23
+ if (!pr) return { canRetry: false, reason: "agent run has no PR result" };
24
+ if (pr.openStatus !== "pending") {
25
+ return { canRetry: false, reason: "agent run PR is not pending" };
26
+ }
27
+ if (!pr.selectedRepoFullName) {
28
+ return { canRetry: false, reason: "agent run has no selected repository" };
29
+ }
30
+ if (!pr.baseBranch) {
31
+ return { canRetry: false, reason: "agent run has no base branch" };
32
+ }
33
+ if (!hasReusablePatch(pr)) {
34
+ return { canRetry: false, reason: "agent run has no reusable patch" };
35
+ }
36
+
37
+ return { canRetry: true };
38
+ }
39
+
40
+ function hasReusablePatch(pr: AgentRunResult["pr"]): boolean {
41
+ if (!pr) return false;
42
+ if (typeof pr.patch === "string" && pr.patch.trim().length > 0) return true;
43
+ if (typeof pr.patchFileId === "string" && pr.patchFileId.trim().length > 0) return true;
44
+ if (typeof pr.patchFilePath === "string" && pr.patchFilePath.trim().length > 0) return true;
45
+ return false;
46
+ }
superlog/apps/api/src/incidents/pr-view.test.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { buildIncidentPullRequestViews } from "./pr-view.js";
4
+
5
+ test("incident PR view includes the recorded PR patch from its agent run", () => {
6
+ const now = new Date("2026-06-06T12:00:00.000Z");
7
+ const views = buildIncidentPullRequestViews(
8
+ [
9
+ {
10
+ id: "pr-1",
11
+ agentRunId: "run-1",
12
+ repoFullName: "acme/app",
13
+ prNumber: 42,
14
+ url: "https://github.com/acme/app/pull/42",
15
+ branchName: "ash/fix-checkout",
16
+ baseBranch: "main",
17
+ headSha: "abc123",
18
+ state: "open",
19
+ title: "Fix checkout",
20
+ createdAt: now,
21
+ updatedAt: now,
22
+ mergedAt: null,
23
+ closedAt: null,
24
+ },
25
+ ],
26
+ [
27
+ {
28
+ id: "run-1",
29
+ result: {
30
+ state: "complete",
31
+ summary: "Fixed it",
32
+ pr: {
33
+ selectedRepoFullName: "acme/app",
34
+ branchName: "ash/fix-checkout",
35
+ baseBranch: "main",
36
+ validationPassed: true,
37
+ openStatus: "opened",
38
+ patch: "diff --git a/app.ts b/app.ts\n+ok\n",
39
+ },
40
+ },
41
+ },
42
+ ],
43
+ );
44
+
45
+ assert.equal(views.length, 1);
46
+ assert.equal(views[0]?.repoFullName, "acme/app");
47
+ assert.equal(views[0]?.patch, "diff --git a/app.ts b/app.ts\n+ok\n");
48
+ assert.equal(views[0]?.createdAt, "2026-06-06T12:00:00.000Z");
49
+ });
50
+
51
+ test("incident PR view returns null patch when the agent run has no patch body", () => {
52
+ const now = new Date("2026-06-06T12:00:00.000Z");
53
+ const views = buildIncidentPullRequestViews(
54
+ [
55
+ {
56
+ id: "pr-1",
57
+ agentRunId: "run-1",
58
+ repoFullName: "acme/app",
59
+ prNumber: 42,
60
+ url: "https://github.com/acme/app/pull/42",
61
+ branchName: "ash/fix-checkout",
62
+ baseBranch: "main",
63
+ headSha: null,
64
+ state: "open",
65
+ title: null,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ mergedAt: null,
69
+ closedAt: null,
70
+ },
71
+ ],
72
+ [{ id: "run-1", result: { state: "complete", summary: "Fixed it", pr: null } }],
73
+ );
74
+
75
+ assert.equal(views[0]?.patch, null);
76
+ });
superlog/apps/api/src/incidents/pr-view.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { schema } from "@superlog/db";
2
+
3
+ export type IncidentPullRequestView = {
4
+ id: string;
5
+ agentRunId: string;
6
+ repoFullName: string;
7
+ prNumber: number;
8
+ url: string;
9
+ branchName: string;
10
+ baseBranch: string;
11
+ headSha: string | null;
12
+ state: schema.AgentPrState;
13
+ title: string | null;
14
+ patch: string | null;
15
+ createdAt: string;
16
+ updatedAt: string;
17
+ mergedAt: string | null;
18
+ closedAt: string | null;
19
+ };
20
+
21
+ type AgentRunWithResult = Pick<schema.AgentRun, "id" | "result">;
22
+ type AgentPullRequestRow = Pick<
23
+ schema.AgentPullRequest,
24
+ | "id"
25
+ | "agentRunId"
26
+ | "repoFullName"
27
+ | "prNumber"
28
+ | "url"
29
+ | "branchName"
30
+ | "baseBranch"
31
+ | "headSha"
32
+ | "state"
33
+ | "title"
34
+ | "createdAt"
35
+ | "updatedAt"
36
+ | "mergedAt"
37
+ | "closedAt"
38
+ >;
39
+
40
+ export function buildIncidentPullRequestViews(
41
+ prs: AgentPullRequestRow[],
42
+ agentRuns: AgentRunWithResult[],
43
+ ): IncidentPullRequestView[] {
44
+ const runsById = new Map(agentRuns.map((run) => [run.id, run] as const));
45
+ return prs.map((pr) => {
46
+ const run = runsById.get(pr.agentRunId) ?? null;
47
+ const runPr = run?.result?.pr ?? null;
48
+ const patch = typeof runPr?.patch === "string" && runPr.patch.trim() ? runPr.patch : null;
49
+ return {
50
+ id: pr.id,
51
+ agentRunId: pr.agentRunId,
52
+ repoFullName: pr.repoFullName,
53
+ prNumber: pr.prNumber,
54
+ url: pr.url,
55
+ branchName: pr.branchName,
56
+ baseBranch: pr.baseBranch,
57
+ headSha: pr.headSha,
58
+ state: pr.state,
59
+ title: pr.title,
60
+ patch,
61
+ createdAt: pr.createdAt.toISOString(),
62
+ updatedAt: pr.updatedAt.toISOString(),
63
+ mergedAt: pr.mergedAt?.toISOString() ?? null,
64
+ closedAt: pr.closedAt?.toISOString() ?? null,
65
+ };
66
+ });
67
+ }
superlog/apps/api/src/incidents/resolution-side-effects.test.ts ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ buildResolvedIncidentSlackRoot,
5
+ runResolvedIncidentSideEffects,
6
+ } from "./resolution-side-effects.js";
7
+
8
+ test("runResolvedIncidentSideEffects closes incident PRs through the shared helper and refreshes Slack root", async () => {
9
+ const calls: string[] = [];
10
+
11
+ const result = await runResolvedIncidentSideEffects({
12
+ incident: {
13
+ id: "inc-1",
14
+ title: "Checkout API timeout",
15
+ service: "checkout-api",
16
+ },
17
+ projectName: "Acme",
18
+ deps: {
19
+ closeIncidentPullRequests: async (incidentId) => {
20
+ calls.push(`close-prs:${incidentId}`);
21
+ return { closedPullRequestCount: 2, failedPullRequestCount: 0 };
22
+ },
23
+ updateSlackRootMessage: async (input) => {
24
+ calls.push(`slack:${input.incident.id}:${input.text}`);
25
+ },
26
+ },
27
+ });
28
+
29
+ assert.deepEqual(result, { closedPullRequestCount: 2, failedPullRequestCount: 0 });
30
+ assert.deepEqual(calls, [
31
+ "close-prs:inc-1",
32
+ "slack:inc-1::white_check_mark: Checkout API timeout - Incident resolved",
33
+ ]);
34
+ });
35
+
36
+ test("runResolvedIncidentSideEffects still refreshes Slack when PR closure reports failures", async () => {
37
+ const calls: string[] = [];
38
+ const result = await runResolvedIncidentSideEffects({
39
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
40
+ projectName: "Acme",
41
+ deps: {
42
+ closeIncidentPullRequests: async () => {
43
+ calls.push("close-prs");
44
+ return { closedPullRequestCount: 0, failedPullRequestCount: 1 };
45
+ },
46
+ updateSlackRootMessage: async () => {
47
+ calls.push("slack");
48
+ },
49
+ },
50
+ });
51
+
52
+ assert.deepEqual(result, { closedPullRequestCount: 0, failedPullRequestCount: 1 });
53
+ assert.deepEqual(calls, ["close-prs", "slack"]);
54
+ });
55
+
56
+ test("runResolvedIncidentSideEffects still refreshes Slack when PR closure throws", async () => {
57
+ const calls: string[] = [];
58
+ const result = await runResolvedIncidentSideEffects({
59
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
60
+ projectName: "Acme",
61
+ deps: {
62
+ closeIncidentPullRequests: async () => {
63
+ calls.push("close-prs");
64
+ throw new Error("github unavailable");
65
+ },
66
+ updateSlackRootMessage: async () => {
67
+ calls.push("slack");
68
+ },
69
+ },
70
+ });
71
+
72
+ assert.deepEqual(result, { closedPullRequestCount: 0, failedPullRequestCount: 1 });
73
+ assert.deepEqual(calls, ["close-prs", "slack"]);
74
+ });
75
+
76
+ test("runResolvedIncidentSideEffects does not fail when Slack refresh throws", async () => {
77
+ const result = await runResolvedIncidentSideEffects({
78
+ incident: { id: "inc-1", title: "Checkout API timeout", service: null },
79
+ projectName: "Acme",
80
+ deps: {
81
+ closeIncidentPullRequests: async () => ({
82
+ closedPullRequestCount: 1,
83
+ failedPullRequestCount: 0,
84
+ }),
85
+ updateSlackRootMessage: async () => {
86
+ throw new Error("slack unavailable");
87
+ },
88
+ },
89
+ });
90
+
91
+ assert.deepEqual(result, { closedPullRequestCount: 1, failedPullRequestCount: 0 });
92
+ });
93
+
94
+ test("buildResolvedIncidentSlackRoot removes resolve action and keeps feedback action", () => {
95
+ const update = buildResolvedIncidentSlackRoot({
96
+ incident: {
97
+ id: "inc-1",
98
+ title: "Checkout API timeout",
99
+ service: "checkout-api",
100
+ },
101
+ projectName: "Acme",
102
+ });
103
+
104
+ assert.equal(update.text, ":white_check_mark: Checkout API timeout - Incident resolved");
105
+ assert.equal(JSON.stringify(update.blocks).includes("resolve_incident:"), false);
106
+ assert.equal(JSON.stringify(update.blocks).includes("give_feedback:inc-1"), true);
107
+ });
superlog/apps/api/src/incidents/resolution-side-effects.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { CloseIncidentOpenPullRequestsResult, CloseIncidentPullRequest } from "@superlog/db";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { logger } from "../logger.js";
4
+
5
+ const WEB_ORIGIN = process.env.WEB_ORIGIN ?? "http://localhost:5173";
6
+
7
+ const log = logger.child({ scope: "incident-resolution-side-effects" });
8
+
9
+ export type ResolvedIncidentSideEffectIncident = {
10
+ id: string;
11
+ title: string;
12
+ service: string | null;
13
+ };
14
+
15
+ export type ResolvedIncidentSlackRoot = {
16
+ text: string;
17
+ blocks: unknown[];
18
+ };
19
+
20
+ export type ResolvedIncidentSideEffectDeps = {
21
+ closeIncidentPullRequests(incidentId: string): Promise<CloseIncidentOpenPullRequestsResult>;
22
+ updateSlackRootMessage(input: {
23
+ incident: ResolvedIncidentSideEffectIncident;
24
+ text: string;
25
+ blocks: unknown[];
26
+ }): Promise<void>;
27
+ };
28
+
29
+ export async function runResolvedIncidentSideEffects(opts: {
30
+ incident: ResolvedIncidentSideEffectIncident;
31
+ projectName: string;
32
+ deps: ResolvedIncidentSideEffectDeps;
33
+ }): Promise<CloseIncidentOpenPullRequestsResult> {
34
+ let closed: CloseIncidentOpenPullRequestsResult;
35
+ try {
36
+ closed = await opts.deps.closeIncidentPullRequests(opts.incident.id);
37
+ } catch (err) {
38
+ log.warn({ err, incident_id: opts.incident.id }, "failed to close incident PRs after resolve");
39
+ closed = { closedPullRequestCount: 0, failedPullRequestCount: 1 };
40
+ }
41
+
42
+ const slackRoot = buildResolvedIncidentSlackRoot({
43
+ incident: opts.incident,
44
+ projectName: opts.projectName,
45
+ });
46
+ try {
47
+ await opts.deps.updateSlackRootMessage({
48
+ incident: opts.incident,
49
+ text: slackRoot.text,
50
+ blocks: slackRoot.blocks,
51
+ });
52
+ } catch (err) {
53
+ log.warn(
54
+ { err, incident_id: opts.incident.id },
55
+ "failed to update resolved incident Slack root",
56
+ );
57
+ }
58
+
59
+ return closed;
60
+ }
61
+
62
+ export async function runResolvedIncidentSideEffectsForIncident(opts: {
63
+ incidentId: string;
64
+ closePullRequest: CloseIncidentPullRequest;
65
+ }): Promise<CloseIncidentOpenPullRequestsResult | null> {
66
+ const { db, schema } = await import("@superlog/db");
67
+ const incident = await db.query.incidents.findFirst({
68
+ where: eq(schema.incidents.id, opts.incidentId),
69
+ });
70
+ if (!incident) return null;
71
+ const project = await db.query.projects.findFirst({
72
+ where: eq(schema.projects.id, incident.projectId),
73
+ });
74
+ return runResolvedIncidentSideEffects({
75
+ incident: {
76
+ id: incident.id,
77
+ title: incident.title,
78
+ service: incident.service,
79
+ },
80
+ projectName: project?.name ?? incident.projectId,
81
+ deps: createResolvedIncidentSideEffectDeps({
82
+ closePullRequest: opts.closePullRequest,
83
+ }),
84
+ });
85
+ }
86
+
87
+ export function buildResolvedIncidentSlackRoot(opts: {
88
+ incident: ResolvedIncidentSideEffectIncident;
89
+ projectName: string;
90
+ }): ResolvedIncidentSlackRoot {
91
+ const incidentUrl = `${WEB_ORIGIN}/incidents/${opts.incident.id}`;
92
+ const lines = [
93
+ ":white_check_mark: *Incident resolved*",
94
+ `*${opts.incident.title}*`,
95
+ opts.incident.service
96
+ ? `\`${opts.projectName}\` · \`${opts.incident.service}\``
97
+ : `\`${opts.projectName}\``,
98
+ ];
99
+ const blocks: unknown[] = [
100
+ { type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
101
+ {
102
+ type: "actions",
103
+ elements: [
104
+ {
105
+ type: "button",
106
+ text: { type: "plain_text", text: "Open in Superlog", emoji: true },
107
+ url: incidentUrl,
108
+ action_id: "open_superlog",
109
+ },
110
+ {
111
+ type: "button",
112
+ text: { type: "plain_text", text: "💬 Give feedback", emoji: true },
113
+ action_id: `give_feedback:${opts.incident.id}`,
114
+ },
115
+ ],
116
+ },
117
+ ];
118
+ return {
119
+ text: `:white_check_mark: ${opts.incident.title} - Incident resolved`,
120
+ blocks,
121
+ };
122
+ }
123
+
124
+ export function createResolvedIncidentSideEffectDeps(opts: {
125
+ closePullRequest: CloseIncidentPullRequest;
126
+ updateSlackRootMessage?: ResolvedIncidentSideEffectDeps["updateSlackRootMessage"];
127
+ }): ResolvedIncidentSideEffectDeps {
128
+ return {
129
+ closeIncidentPullRequests: async (incidentId) => {
130
+ const { closeIncidentOpenPullRequestsAfterResolution } = await import("@superlog/db");
131
+ return closeIncidentOpenPullRequestsAfterResolution({
132
+ incidentId,
133
+ closePullRequest: opts.closePullRequest,
134
+ onCloseFailure: ({ pr, error }) =>
135
+ log.warn(
136
+ {
137
+ incident_id: incidentId,
138
+ agent_pr_id: pr.id,
139
+ repo: pr.repoFullName,
140
+ pr_number: pr.prNumber,
141
+ error,
142
+ },
143
+ "failed to close incident PR after resolve",
144
+ ),
145
+ });
146
+ },
147
+ updateSlackRootMessage: opts.updateSlackRootMessage ?? updateResolvedIncidentSlackRootMessage,
148
+ };
149
+ }
150
+
151
+ export async function updateResolvedIncidentSlackRootMessage(input: {
152
+ incident: ResolvedIncidentSideEffectIncident;
153
+ text: string;
154
+ blocks: unknown[];
155
+ }): Promise<void> {
156
+ const { db, schema } = await import("@superlog/db");
157
+ const row = await db.query.incidents.findFirst({
158
+ where: eq(schema.incidents.id, input.incident.id),
159
+ });
160
+ if (!row?.slackChannelId || !row.slackThreadTs || !row.slackInstallationId) return;
161
+
162
+ const installation = await db.query.slackInstallations.findFirst({
163
+ where: and(
164
+ eq(schema.slackInstallations.id, row.slackInstallationId),
165
+ eq(schema.slackInstallations.projectId, row.projectId),
166
+ ),
167
+ });
168
+ if (!installation?.botAccessToken || installation.revokedAt) return;
169
+
170
+ try {
171
+ const res = await fetch("https://slack.com/api/chat.update", {
172
+ method: "POST",
173
+ headers: {
174
+ "content-type": "application/json; charset=utf-8",
175
+ authorization: `Bearer ${installation.botAccessToken}`,
176
+ },
177
+ body: JSON.stringify({
178
+ channel: row.slackChannelId,
179
+ ts: row.slackThreadTs,
180
+ text: input.text,
181
+ blocks: input.blocks,
182
+ }),
183
+ });
184
+ const data = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
185
+ if (!data?.ok) {
186
+ log.warn(
187
+ {
188
+ incident_id: input.incident.id,
189
+ channel: row.slackChannelId,
190
+ error: data?.error ?? `status_${res.status}`,
191
+ },
192
+ "failed to update resolved incident Slack root",
193
+ );
194
+ }
195
+ } catch (err) {
196
+ log.warn({ err, incident_id: input.incident.id }, "resolved incident Slack root update threw");
197
+ }
198
+ }
superlog/apps/api/src/incidents/search.test.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import type { Incident } from "@superlog/db";
4
+
5
+ // search.ts transitively imports the db client, which throws at import time
6
+ // without a connection string. Set a dummy URL before the dynamic import (the
7
+ // porsager client connects lazily, so these pure-function tests never open a
8
+ // socket). Same dynamic-import pattern as linear.test.ts / loops.test.ts.
9
+ process.env.DATABASE_URL ??= "postgres://localhost:5434/superlog";
10
+ const { resolveIncidentSearchStatus, toIncidentSummary } = await import("./search.js");
11
+
12
+ test("status defaults to hiding agent-classified noise", () => {
13
+ assert.deepEqual(resolveIncidentSearchStatus(undefined), { kind: "exclude_noise" });
14
+ });
15
+
16
+ test("status='all' includes every status, noise included", () => {
17
+ assert.deepEqual(resolveIncidentSearchStatus("all"), { kind: "all" });
18
+ });
19
+
20
+ test("a concrete status narrows to exactly that status", () => {
21
+ assert.deepEqual(resolveIncidentSearchStatus("open"), { kind: "only", status: "open" });
22
+ assert.deepEqual(resolveIncidentSearchStatus("autoresolved_noise"), {
23
+ kind: "only",
24
+ status: "autoresolved_noise",
25
+ });
26
+ });
27
+
28
+ test("an unknown status is rejected rather than silently ignored", () => {
29
+ assert.throws(() => resolveIncidentSearchStatus("bogus"), /invalid incident status/i);
30
+ });
31
+
32
+ test("summary is a compact, agent-friendly projection with ISO timestamps", () => {
33
+ const summary = toIncidentSummary(baseIncident());
34
+
35
+ assert.equal(summary.id, "incident-1");
36
+ assert.equal(summary.projectId, "project-1");
37
+ assert.equal(summary.codename, "steady-amber");
38
+ assert.equal(summary.title, "Checkout failures");
39
+ assert.equal(summary.service, "api");
40
+ assert.equal(summary.environment, "production");
41
+ assert.equal(summary.severity, "SEV-2");
42
+ assert.equal(summary.status, "open");
43
+ assert.equal(summary.issueCount, 1);
44
+ assert.equal(summary.firstSeen, "2026-05-24T00:00:00.000Z");
45
+ assert.equal(summary.lastSeen, "2026-05-24T01:00:00.000Z");
46
+ assert.equal(summary.resolvedAt, null);
47
+ // Heavy/internal columns must not leak into the MCP payload.
48
+ assert.equal("noiseClassification" in summary, false);
49
+ assert.equal("slackInstallationId" in summary, false);
50
+ });
51
+
52
+ test("summary surfaces agent findings and resolution metadata when present", () => {
53
+ const summary = toIncidentSummary({
54
+ ...baseIncident(),
55
+ status: "resolved",
56
+ agentSummary: "DB pool exhausted",
57
+ rootCauseText: "connection leak in checkout handler",
58
+ rootCauseConfidence: 80,
59
+ resolvedAt: new Date("2026-05-25T00:00:00.000Z"),
60
+ resolvedReasonCode: "agent_pr_merged",
61
+ });
62
+
63
+ assert.equal(summary.agentSummary, "DB pool exhausted");
64
+ assert.equal(summary.rootCauseText, "connection leak in checkout handler");
65
+ assert.equal(summary.rootCauseConfidence, 80);
66
+ assert.equal(summary.resolvedAt, "2026-05-25T00:00:00.000Z");
67
+ assert.equal(summary.resolvedReasonCode, "agent_pr_merged");
68
+ });
69
+
70
+ function baseIncident(): Incident {
71
+ return {
72
+ id: "incident-1",
73
+ projectId: "project-1",
74
+ service: "api",
75
+ environment: "production",
76
+ title: "Checkout failures",
77
+ codename: "steady-amber",
78
+ severity: "SEV-2",
79
+ status: "open",
80
+ noiseReason: null,
81
+ noiseResolvedAt: null,
82
+ mergedAt: null,
83
+ slackInstallationId: null,
84
+ lastSlackPostedAt: null,
85
+ firstSeen: new Date("2026-05-24T00:00:00.000Z"),
86
+ lastSeen: new Date("2026-05-24T01:00:00.000Z"),
87
+ issueCount: 1,
88
+ slackChannelId: null,
89
+ slackThreadTs: null,
90
+ autoInvestigateSuppressedUntil: null,
91
+ autorecoveryLastEvaluatedAt: null,
92
+ agentSummary: null,
93
+ rootCauseText: null,
94
+ rootCauseConfidence: null,
95
+ estimatedImpactText: null,
96
+ estimatedImpactConfidence: null,
97
+ suggestedSeverity: null,
98
+ noiseClassification: null,
99
+ resolutionClassification: null,
100
+ findingsAgentRunId: null,
101
+ resolvedByKind: null,
102
+ resolvedByUserId: null,
103
+ resolvedBySlackUserId: null,
104
+ resolvedReasonCode: null,
105
+ resolvedReasonText: null,
106
+ resolvedAt: null,
107
+ mergedIntoId: null,
108
+ createdAt: new Date("2026-05-24T00:00:00.000Z"),
109
+ updatedAt: new Date("2026-05-24T00:00:00.000Z"),
110
+ };
111
+ }
superlog/apps/api/src/incidents/search.ts ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ type Incident,
3
+ type IncidentSeverity,
4
+ type IncidentStatus,
5
+ db,
6
+ schema,
7
+ } from "@superlog/db";
8
+ import { and, desc, eq, gte, ilike, lte, ne, or } from "drizzle-orm";
9
+
10
+ const INCIDENT_STATUSES: readonly IncidentStatus[] = [
11
+ "open",
12
+ "resolved",
13
+ "autoresolved_noise",
14
+ "merged",
15
+ ];
16
+
17
+ function isIncidentStatus(value: string): value is IncidentStatus {
18
+ return (INCIDENT_STATUSES as readonly string[]).includes(value);
19
+ }
20
+
21
+ export type IncidentSearchStatusFilter =
22
+ | { kind: "exclude_noise" }
23
+ | { kind: "all" }
24
+ | { kind: "only"; status: IncidentStatus };
25
+
26
+ /**
27
+ * Mirror the web list endpoint's default: with no status given, hide
28
+ * agent-classified noise. `all` opts into every status; any concrete status
29
+ * narrows to exactly that one. An unrecognised value is rejected so a typo
30
+ * doesn't silently return the whole backlog.
31
+ */
32
+ export function resolveIncidentSearchStatus(
33
+ status: string | undefined,
34
+ ): IncidentSearchStatusFilter {
35
+ if (!status) return { kind: "exclude_noise" };
36
+ if (status === "all") return { kind: "all" };
37
+ if (isIncidentStatus(status)) return { kind: "only", status };
38
+ throw new Error(`invalid incident status: ${status}`);
39
+ }
40
+
41
+ export type IncidentSummary = {
42
+ id: string;
43
+ projectId: string;
44
+ codename: string;
45
+ title: string;
46
+ service: string | null;
47
+ environment: string | null;
48
+ severity: IncidentSeverity | null;
49
+ status: IncidentStatus;
50
+ firstSeen: string;
51
+ lastSeen: string;
52
+ issueCount: number;
53
+ agentSummary: string | null;
54
+ rootCauseText: string | null;
55
+ rootCauseConfidence: number | null;
56
+ estimatedImpactText: string | null;
57
+ resolvedAt: string | null;
58
+ resolvedReasonCode: string | null;
59
+ mergedIntoId: string | null;
60
+ slackChannelId: string | null;
61
+ slackThreadTs: string | null;
62
+ };
63
+
64
+ /**
65
+ * Compact, agent-friendly projection of an incident. Drops the heavy/internal
66
+ * columns (noise & resolution classification blobs, slack install pointers,
67
+ * autorecovery bookkeeping) and renders timestamps as ISO strings.
68
+ */
69
+ export function toIncidentSummary(incident: Incident): IncidentSummary {
70
+ return {
71
+ id: incident.id,
72
+ projectId: incident.projectId,
73
+ codename: incident.codename,
74
+ title: incident.title,
75
+ service: incident.service,
76
+ environment: incident.environment,
77
+ severity: incident.severity,
78
+ status: incident.status,
79
+ firstSeen: incident.firstSeen.toISOString(),
80
+ lastSeen: incident.lastSeen.toISOString(),
81
+ issueCount: incident.issueCount,
82
+ agentSummary: incident.agentSummary,
83
+ rootCauseText: incident.rootCauseText,
84
+ rootCauseConfidence: incident.rootCauseConfidence,
85
+ estimatedImpactText: incident.estimatedImpactText,
86
+ resolvedAt: incident.resolvedAt ? incident.resolvedAt.toISOString() : null,
87
+ resolvedReasonCode: incident.resolvedReasonCode,
88
+ mergedIntoId: incident.mergedIntoId,
89
+ slackChannelId: incident.slackChannelId,
90
+ slackThreadTs: incident.slackThreadTs,
91
+ };
92
+ }
93
+
94
+ export type IncidentSearchInput = {
95
+ status?: string;
96
+ severity?: IncidentSeverity;
97
+ service?: string;
98
+ query?: string;
99
+ since?: string;
100
+ until?: string;
101
+ limit?: number;
102
+ };
103
+
104
+ function parseTimestamp(value: string | undefined, label: string): Date | undefined {
105
+ if (!value) return undefined;
106
+ const date = new Date(value);
107
+ if (Number.isNaN(date.getTime())) {
108
+ throw new Error(`invalid ${label} timestamp: ${value}`);
109
+ }
110
+ return date;
111
+ }
112
+
113
+ function clampLimit(limit: number | undefined): number {
114
+ if (!limit || !Number.isFinite(limit)) return 50;
115
+ return Math.min(Math.max(Math.trunc(limit), 1), 200);
116
+ }
117
+
118
+ /**
119
+ * Search a project's incidents by status, severity, service, free-text
120
+ * (title/codename substring), and `last_seen` window. Newest activity first.
121
+ */
122
+ export async function searchIncidents(
123
+ projectId: string,
124
+ input: IncidentSearchInput,
125
+ ): Promise<IncidentSummary[]> {
126
+ const conditions = [eq(schema.incidents.projectId, projectId)];
127
+
128
+ const status = resolveIncidentSearchStatus(input.status);
129
+ if (status.kind === "exclude_noise") {
130
+ conditions.push(ne(schema.incidents.status, "autoresolved_noise"));
131
+ } else if (status.kind === "only") {
132
+ conditions.push(eq(schema.incidents.status, status.status));
133
+ }
134
+
135
+ if (input.severity) conditions.push(eq(schema.incidents.severity, input.severity));
136
+ if (input.service) conditions.push(eq(schema.incidents.service, input.service));
137
+
138
+ if (input.query) {
139
+ const like = `%${input.query}%`;
140
+ const textMatch = or(
141
+ ilike(schema.incidents.title, like),
142
+ ilike(schema.incidents.codename, like),
143
+ );
144
+ if (textMatch) conditions.push(textMatch);
145
+ }
146
+
147
+ const since = parseTimestamp(input.since, "since");
148
+ if (since) conditions.push(gte(schema.incidents.lastSeen, since));
149
+ const until = parseTimestamp(input.until, "until");
150
+ if (until) conditions.push(lte(schema.incidents.lastSeen, until));
151
+
152
+ const rows = await db.query.incidents.findMany({
153
+ where: and(...conditions),
154
+ orderBy: [desc(schema.incidents.lastSeen)],
155
+ limit: clampLimit(input.limit),
156
+ });
157
+
158
+ return rows.map(toIncidentSummary);
159
+ }