Initial public upload from Projekte/Experimente
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .codex/config.toml +31 -0
- superlog/.codex/config.toml +31 -0
- superlog/.dockerignore +18 -0
- superlog/.gitignore +16 -0
- superlog/.nvmrc +1 -0
- superlog/LICENSE.md +202 -0
- superlog/Procfile +4 -0
- superlog/Procfile.portless +4 -0
- superlog/README.md +112 -0
- superlog/SAMUEL_LOCAL_SETUP.md +50 -0
- superlog/SECURITY.md +67 -0
- superlog/apps/api/Dockerfile +23 -0
- superlog/apps/api/node_modules/.bin/nanoid +17 -0
- superlog/apps/api/node_modules/.bin/pino +17 -0
- superlog/apps/api/node_modules/.bin/tsc +17 -0
- superlog/apps/api/node_modules/.bin/tsserver +17 -0
- superlog/apps/api/node_modules/.bin/tsx +17 -0
- superlog/apps/api/package.json +51 -0
- superlog/apps/api/scripts/test-agent-tracking-e2e.ts +634 -0
- superlog/apps/api/scripts/test-mcp-clickhouse-ranges.ts +72 -0
- superlog/apps/api/src/alerts-service.ts +391 -0
- superlog/apps/api/src/alerts.ts +97 -0
- superlog/apps/api/src/auth.ts +217 -0
- superlog/apps/api/src/boot-migrations.test.ts +28 -0
- superlog/apps/api/src/boot-migrations.ts +15 -0
- superlog/apps/api/src/dashboards-service.ts +252 -0
- superlog/apps/api/src/dashboards.ts +136 -0
- superlog/apps/api/src/email.ts +99 -0
- superlog/apps/api/src/env.ts +8 -0
- superlog/apps/api/src/feedback.ts +299 -0
- superlog/apps/api/src/gateway.ts +612 -0
- superlog/apps/api/src/github-branches.test.ts +51 -0
- superlog/apps/api/src/github-branches.ts +44 -0
- superlog/apps/api/src/github.test.ts +297 -0
- superlog/apps/api/src/github.ts +2037 -0
- superlog/apps/api/src/http-observability.test.ts +55 -0
- superlog/apps/api/src/http-observability.ts +100 -0
- superlog/apps/api/src/impersonation.ts +68 -0
- superlog/apps/api/src/incidents/detail.test.ts +74 -0
- superlog/apps/api/src/incidents/detail.ts +115 -0
- superlog/apps/api/src/incidents/list.test.ts +91 -0
- superlog/apps/api/src/incidents/list.ts +59 -0
- superlog/apps/api/src/incidents/pr-retry.test.ts +64 -0
- superlog/apps/api/src/incidents/pr-retry.ts +46 -0
- superlog/apps/api/src/incidents/pr-view.test.ts +76 -0
- superlog/apps/api/src/incidents/pr-view.ts +67 -0
- superlog/apps/api/src/incidents/resolution-side-effects.test.ts +107 -0
- superlog/apps/api/src/incidents/resolution-side-effects.ts +198 -0
- superlog/apps/api/src/incidents/search.test.ts +111 -0
- 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 |
+
[](https://github.com/superloglabs/superlog/commits/main)
|
| 12 |
+
[](https://github.com/superloglabs/superlog/graphs/commit-activity)
|
| 13 |
+
[](./LICENSE.md)
|
| 14 |
+
<br>
|
| 15 |
+

|
| 16 |
+
<a href="https://www.ycombinator.com"><img src="https://img.shields.io/badge/Y%20Combinator-P26-orange" alt="Y Combinator P26"></a>
|
| 17 |
+
[](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, "&")
|
| 57 |
+
.replace(/</g, "<")
|
| 58 |
+
.replace(/>/g, ">")
|
| 59 |
+
.replace(/"/g, """);
|
| 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 |
+
}
|