@sentry/junior-github 0.106.0 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ gitHubPullRequestSubscribable,
3
+ normalizeGitHubResourceEvents
4
+ } from "./chunk-JDNXIBCQ.js";
5
+
1
6
  // src/index.ts
2
7
  import { createPrivateKey, createSign } from "crypto";
3
8
  import {
@@ -293,14 +298,14 @@ function parseRepo(value) {
293
298
  };
294
299
  }
295
300
  async function readJsonResponse(response) {
296
- const text = await response.text();
297
- if (!text) {
301
+ const text2 = await response.text();
302
+ if (!text2) {
298
303
  return void 0;
299
304
  }
300
305
  try {
301
- return JSON.parse(text);
306
+ return JSON.parse(text2);
302
307
  } catch {
303
- return text;
308
+ return text2;
304
309
  }
305
310
  }
306
311
  function githubApiErrorMessage(payload) {
@@ -473,41 +478,6 @@ import { Type as Type2 } from "@sinclair/typebox";
473
478
  import { Value as Value2 } from "@sinclair/typebox/value";
474
479
  import { z as z2 } from "zod";
475
480
  import { subscribableResourceSchema } from "@sentry/junior-plugin-api";
476
-
477
- // src/resource-events/pull-request.ts
478
- var SUPPORTED_EVENTS = [
479
- "checks.failed",
480
- "checks.recovered",
481
- "comment.created",
482
- "review.approved",
483
- "review.changes_requested",
484
- "review.commented",
485
- "review_comment.created",
486
- "state.merged",
487
- "state.closed_unmerged"
488
- ];
489
- var SUGGESTED_EVENTS = [
490
- "checks.failed",
491
- "comment.created",
492
- "review.changes_requested",
493
- "review.commented",
494
- "review_comment.created",
495
- "state.merged",
496
- "state.closed_unmerged"
497
- ];
498
- function gitHubPullRequestSubscribable(input) {
499
- if (!process.env.GITHUB_WEBHOOK_SECRET?.trim()) return void 0;
500
- return {
501
- label: `GitHub PR ${input.repo}#${input.number}`,
502
- provider: "github",
503
- resourceRef: `github:pull_request:${input.repo}#${input.number}`,
504
- suggestedEvents: SUGGESTED_EVENTS,
505
- supportedEvents: SUPPORTED_EVENTS,
506
- type: "pull_request"
507
- };
508
- }
509
-
510
- // src/tools/create-pull-request.ts
511
481
  var GITHUB_PULL_REQUEST_CREATE_IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
512
482
  var GITHUB_PULL_REQUEST_CREATE_LOCK_TTL_MS = 6e4;
513
483
  var GitHubPullRequestCreateRejectedError = class extends Error {
@@ -614,14 +584,14 @@ function parseRepo2(value) {
614
584
  };
615
585
  }
616
586
  async function readJsonResponse2(response) {
617
- const text = await response.text();
618
- if (!text) {
587
+ const text2 = await response.text();
588
+ if (!text2) {
619
589
  return void 0;
620
590
  }
621
591
  try {
622
- return JSON.parse(text);
592
+ return JSON.parse(text2);
623
593
  } catch {
624
- return text;
594
+ return text2;
625
595
  }
626
596
  }
627
597
  function githubApiErrorMessage2(payload) {
@@ -866,12 +836,12 @@ function parseRepo3(value) {
866
836
  return { owner: parts[0], name: parts[1], ref: `${parts[0]}/${parts[1]}` };
867
837
  }
868
838
  async function readJson(response) {
869
- const text = await response.text();
870
- if (!text) return void 0;
839
+ const text2 = await response.text();
840
+ if (!text2) return void 0;
871
841
  try {
872
- return JSON.parse(text);
842
+ return JSON.parse(text2);
873
843
  } catch {
874
- return text;
844
+ return text2;
875
845
  }
876
846
  }
877
847
  function createGitHubGetPullRequestTool(ctx) {
@@ -935,12 +905,592 @@ function createGitHubGetPullRequestTool(ctx) {
935
905
  });
936
906
  }
937
907
 
908
+ // src/tools/update-pull-request.ts
909
+ import {
910
+ definePluginTool as definePluginTool4,
911
+ PluginToolInputError as PluginToolInputError5,
912
+ pluginToolResultSchema as pluginToolResultSchema4
913
+ } from "@sentry/junior-plugin-api";
914
+ import { z as z4 } from "zod";
915
+ import { subscribableResourceSchema as subscribableResourceSchema3 } from "@sentry/junior-plugin-api";
916
+ var inputSchema2 = z4.object({
917
+ repo: z4.string().describe('Repository in "owner/name" format.'),
918
+ number: z4.number().int().positive().describe("Pull request number."),
919
+ title: z4.string().trim().min(1).optional().describe("Replacement pull request title."),
920
+ body: z4.string().optional().describe(
921
+ "Replacement pull request body. Junior appends requester attribution and the conversation footer."
922
+ ),
923
+ base: z4.string().trim().min(1).optional().describe("Replacement base branch."),
924
+ state: z4.enum(["open", "closed"]).optional().describe("Replacement pull request state.")
925
+ }).strict().refine(
926
+ ({ title, body, base, state }) => title !== void 0 || body !== void 0 || base !== void 0 || state !== void 0,
927
+ { message: "At least one pull request field must be provided." }
928
+ );
929
+ var pullRequestSchema2 = z4.object({
930
+ base: z4.string(),
931
+ body: z4.string().nullable(),
932
+ draft: z4.boolean(),
933
+ number: z4.number(),
934
+ state: z4.string(),
935
+ subscribable: subscribableResourceSchema3.optional(),
936
+ title: z4.string(),
937
+ url: z4.string()
938
+ });
939
+ var outputSchema2 = pluginToolResultSchema4.extend({
940
+ ok: z4.literal(true),
941
+ status: z4.literal("success"),
942
+ target: z4.literal("updatePullRequest"),
943
+ data: pullRequestSchema2,
944
+ ...pullRequestSchema2.shape
945
+ });
946
+ function nonEmptyString4(value, name) {
947
+ if (!value?.trim()) {
948
+ throw new PluginToolInputError5(`${name} is required`);
949
+ }
950
+ return value.trim();
951
+ }
952
+ function parseRepo4(value) {
953
+ const parts = value.split("/").map((part) => part.trim());
954
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
955
+ throw new PluginToolInputError5('repo must use "owner/name" format');
956
+ }
957
+ return { owner: parts[0], name: parts[1], ref: `${parts[0]}/${parts[1]}` };
958
+ }
959
+ async function readJson2(response) {
960
+ const text2 = await response.text();
961
+ if (!text2) return void 0;
962
+ try {
963
+ return JSON.parse(text2);
964
+ } catch {
965
+ return text2;
966
+ }
967
+ }
968
+ function githubApiErrorMessage3(payload) {
969
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
970
+ const message = payload.message;
971
+ if (typeof message === "string" && message.trim()) return message.trim();
972
+ }
973
+ if (typeof payload === "string" && payload.trim()) return payload.trim();
974
+ return "GitHub request failed";
975
+ }
976
+ function createGitHubUpdatePullRequestTool(ctx) {
977
+ return definePluginTool4({
978
+ description: "Update an existing GitHub pull request's title, body, base branch, or open/closed state. Use this instead of raw GitHub API calls when changing PR metadata.",
979
+ inputSchema: inputSchema2,
980
+ outputSchema: outputSchema2,
981
+ async execute(input) {
982
+ const parsedInput = inputSchema2.safeParse(input);
983
+ if (!parsedInput.success) {
984
+ throw new PluginToolInputError5(
985
+ "Invalid GitHub updatePullRequest input.",
986
+ { cause: parsedInput.error }
987
+ );
988
+ }
989
+ const update = parsedInput.data;
990
+ const repo = parseRepo4(update.repo);
991
+ const payload = {
992
+ ...update.title !== void 0 ? { title: update.title } : {},
993
+ ...update.body !== void 0 ? {
994
+ body: appendGitHubFooter(
995
+ appendGitHubRequesterAttribution(update.body, ctx.actor),
996
+ nonEmptyString4(ctx.conversationId, "conversationId"),
997
+ ctx.slack?.conversationLink?.url
998
+ )
999
+ } : {},
1000
+ ...update.base !== void 0 ? { base: update.base } : {},
1001
+ ...update.state !== void 0 ? { state: update.state } : {}
1002
+ };
1003
+ const response = await ctx.egress.fetch({
1004
+ provider: "github",
1005
+ operation: "github.pull.update",
1006
+ request: new Request(
1007
+ `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/pulls/${update.number}`,
1008
+ {
1009
+ method: "PATCH",
1010
+ headers: {
1011
+ Accept: "application/vnd.github+json",
1012
+ "Content-Type": "application/json",
1013
+ "X-GitHub-Api-Version": "2022-11-28"
1014
+ },
1015
+ body: JSON.stringify(payload)
1016
+ }
1017
+ )
1018
+ });
1019
+ const parsed = await readJson2(response);
1020
+ if (!response.ok) {
1021
+ throw new Error(
1022
+ `GitHub pull request update failed with HTTP ${response.status}: ${githubApiErrorMessage3(parsed)}`
1023
+ );
1024
+ }
1025
+ const providerResult = z4.object({
1026
+ base: z4.object({ ref: z4.string() }),
1027
+ body: z4.string().nullable().optional().default(null),
1028
+ draft: z4.boolean(),
1029
+ html_url: z4.string(),
1030
+ number: z4.number(),
1031
+ state: z4.string(),
1032
+ title: z4.string()
1033
+ }).parse(parsed);
1034
+ const subscribable = gitHubPullRequestSubscribable({
1035
+ number: providerResult.number,
1036
+ repo: repo.ref
1037
+ });
1038
+ const data = {
1039
+ base: providerResult.base.ref,
1040
+ body: providerResult.body,
1041
+ draft: providerResult.draft,
1042
+ number: providerResult.number,
1043
+ state: providerResult.state,
1044
+ ...subscribable ? { subscribable } : {},
1045
+ title: providerResult.title,
1046
+ url: providerResult.html_url
1047
+ };
1048
+ return {
1049
+ ok: true,
1050
+ status: "success",
1051
+ target: "updatePullRequest",
1052
+ data,
1053
+ ...data
1054
+ };
1055
+ }
1056
+ });
1057
+ }
1058
+
938
1059
  // src/tools.ts
939
1060
  function createGitHubTools(ctx) {
940
1061
  return {
941
1062
  createIssue: createGitHubIssueTool(ctx),
942
1063
  createPullRequest: createGitHubPullRequestTool(ctx),
943
- getPullRequest: createGitHubGetPullRequestTool(ctx)
1064
+ getPullRequest: createGitHubGetPullRequestTool(ctx),
1065
+ updatePullRequest: createGitHubUpdatePullRequestTool(ctx)
1066
+ };
1067
+ }
1068
+
1069
+ // src/webhooks/handler.ts
1070
+ import { createHmac, timingSafeEqual } from "crypto";
1071
+
1072
+ // src/pull-request-outcomes/store.ts
1073
+ import { and, eq, lte } from "drizzle-orm";
1074
+ import { z as z6 } from "zod";
1075
+
1076
+ // src/db/schema.ts
1077
+ import { sql } from "drizzle-orm";
1078
+ import { index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
1079
+ import { z as z5 } from "zod";
1080
+ var githubPullRequestStateSchema = z5.enum([
1081
+ "closed_unmerged",
1082
+ "merged",
1083
+ "open"
1084
+ ]);
1085
+ var juniorGitHubPullRequests = pgTable(
1086
+ "junior_github_pull_requests",
1087
+ {
1088
+ pullRequestId: text("pull_request_id").primaryKey(),
1089
+ repositoryId: text("repository_id").notNull(),
1090
+ repositoryFullName: text("repository_full_name").notNull(),
1091
+ number: integer("number").notNull(),
1092
+ state: text("state").$type().notNull(),
1093
+ openedAt: timestamp("opened_at", { withTimezone: true }).notNull(),
1094
+ mergedAt: timestamp("merged_at", { withTimezone: true }),
1095
+ closedAt: timestamp("closed_at", { withTimezone: true }),
1096
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull()
1097
+ },
1098
+ (table) => [
1099
+ index("junior_github_pull_requests_opened_at_idx").on(table.openedAt),
1100
+ index("junior_github_pull_requests_merged_at_idx").on(table.mergedAt),
1101
+ index("junior_github_pull_requests_closed_at_idx").on(table.closedAt),
1102
+ index("junior_github_pull_requests_open_idx").on(table.pullRequestId).where(sql`${table.state} = 'open'`)
1103
+ ]
1104
+ );
1105
+
1106
+ // src/pull-request-outcomes/store.ts
1107
+ var githubPullRequestOutcomeInputSchema = z6.object({
1108
+ candidateOwned: z6.boolean(),
1109
+ closedAt: z6.date().optional(),
1110
+ mergedAt: z6.date().optional(),
1111
+ number: z6.number().int().positive(),
1112
+ openedAt: z6.date(),
1113
+ pullRequestId: z6.string().min(1),
1114
+ repositoryFullName: z6.string().min(1),
1115
+ repositoryId: z6.string().min(1),
1116
+ state: githubPullRequestStateSchema,
1117
+ updatedAt: z6.date()
1118
+ }).strict();
1119
+ function projectionValues(input) {
1120
+ return {
1121
+ closedAt: input.closedAt ?? null,
1122
+ mergedAt: input.mergedAt ?? null,
1123
+ number: input.number,
1124
+ openedAt: input.openedAt,
1125
+ repositoryFullName: input.repositoryFullName,
1126
+ repositoryId: input.repositoryId,
1127
+ state: input.state,
1128
+ updatedAt: input.updatedAt
1129
+ };
1130
+ }
1131
+ async function recordGitHubPullRequestOutcome(db, input) {
1132
+ const outcome = githubPullRequestOutcomeInputSchema.parse(input);
1133
+ const values = projectionValues(outcome);
1134
+ if (!outcome.candidateOwned) {
1135
+ await db.update(juniorGitHubPullRequests).set(values).where(
1136
+ and(
1137
+ eq(juniorGitHubPullRequests.pullRequestId, outcome.pullRequestId),
1138
+ lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1139
+ )
1140
+ );
1141
+ return;
1142
+ }
1143
+ await db.insert(juniorGitHubPullRequests).values({ pullRequestId: outcome.pullRequestId, ...values }).onConflictDoUpdate({
1144
+ target: juniorGitHubPullRequests.pullRequestId,
1145
+ set: values,
1146
+ where: lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1147
+ });
1148
+ }
1149
+
1150
+ // src/webhooks/pull-request-outcome.ts
1151
+ import { z as z7 } from "zod";
1152
+ var pullRequestOutcomeSchema = z7.object({
1153
+ action: z7.enum(["opened", "closed", "reopened"]),
1154
+ pull_request: z7.object({
1155
+ body: z7.string().nullable().optional(),
1156
+ closed_at: z7.string().nullable().optional(),
1157
+ created_at: z7.string(),
1158
+ id: z7.number().int().positive(),
1159
+ merged: z7.boolean(),
1160
+ merged_at: z7.string().nullable().optional(),
1161
+ number: z7.number().int().positive(),
1162
+ updated_at: z7.string(),
1163
+ user: z7.object({ login: z7.string().min(1) })
1164
+ }),
1165
+ repository: z7.object({
1166
+ full_name: z7.string().min(1),
1167
+ id: z7.number().int().positive()
1168
+ })
1169
+ });
1170
+ var pullRequestLifecycleActionSchema = z7.object({ action: z7.string() });
1171
+ var GITHUB_NOREPLY_DOMAIN = "users.noreply.github.com";
1172
+ function botLoginFromEmail(value) {
1173
+ const email = value?.trim();
1174
+ if (!email) return void 0;
1175
+ const separator = email.lastIndexOf("@");
1176
+ if (separator <= 0) return void 0;
1177
+ const domain = email.slice(separator + 1).toLowerCase();
1178
+ if (domain !== GITHUB_NOREPLY_DOMAIN) return void 0;
1179
+ const localPart = email.slice(0, separator);
1180
+ const login = localPart.slice(localPart.indexOf("+") + 1).trim();
1181
+ return login.toLowerCase().endsWith("[bot]") ? login : void 0;
1182
+ }
1183
+ function timestamp2(value) {
1184
+ if (!value) return void 0;
1185
+ const parsed = new Date(value);
1186
+ return Number.isFinite(parsed.getTime()) ? parsed : void 0;
1187
+ }
1188
+ function normalizeGitHubPullRequestOutcome(args) {
1189
+ const lifecycle = pullRequestLifecycleActionSchema.safeParse(args.body);
1190
+ if (!lifecycle.success || !["opened", "closed", "reopened"].includes(lifecycle.data.action)) {
1191
+ return void 0;
1192
+ }
1193
+ const parsed = pullRequestOutcomeSchema.parse(args.body);
1194
+ const pullRequest = parsed.pull_request;
1195
+ const openedAt = timestamp2(pullRequest.created_at);
1196
+ const updatedAt = timestamp2(pullRequest.updated_at);
1197
+ if (!openedAt || !updatedAt) {
1198
+ throw new Error("GitHub pull request lifecycle timestamps are invalid");
1199
+ }
1200
+ const authorLogin = pullRequest.user.login.trim().toLowerCase();
1201
+ const botLogin = botLoginFromEmail(args.botEmail)?.toLowerCase();
1202
+ if (parsed.action === "opened" && !botLogin) {
1203
+ throw new Error(
1204
+ "The configured GitHub App bot email must encode a [bot] login in GitHub's noreply format to classify pull request ownership"
1205
+ );
1206
+ }
1207
+ const hasOwnershipMarker = Boolean(
1208
+ botLogin && authorLogin === botLogin && pullRequest.body?.includes(GITHUB_SESSION_FOOTER_START)
1209
+ );
1210
+ const candidateOwned = hasOwnershipMarker && (parsed.action === "opened" || parsed.action === "closed");
1211
+ const state = parsed.action !== "closed" ? "open" : pullRequest.merged ? "merged" : "closed_unmerged";
1212
+ const closedAt = timestamp2(pullRequest.closed_at);
1213
+ const mergedAt = timestamp2(pullRequest.merged_at);
1214
+ if (parsed.action === "closed" && (pullRequest.merged ? !mergedAt : !closedAt)) {
1215
+ throw new Error("GitHub pull request terminal timestamp is invalid");
1216
+ }
1217
+ return {
1218
+ candidateOwned,
1219
+ closedAt,
1220
+ mergedAt,
1221
+ number: pullRequest.number,
1222
+ openedAt,
1223
+ pullRequestId: String(pullRequest.id),
1224
+ repositoryFullName: parsed.repository.full_name,
1225
+ repositoryId: String(parsed.repository.id),
1226
+ state,
1227
+ updatedAt
1228
+ };
1229
+ }
1230
+
1231
+ // src/webhooks/handler.ts
1232
+ function verifyGitHubSignature(body, signature, secret) {
1233
+ if (!secret || !signature.startsWith("sha256=")) return false;
1234
+ const actual = Buffer.from(signature);
1235
+ const expected = Buffer.from(
1236
+ `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`
1237
+ );
1238
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
1239
+ }
1240
+ function parseJson(body) {
1241
+ try {
1242
+ return JSON.parse(body);
1243
+ } catch {
1244
+ return void 0;
1245
+ }
1246
+ }
1247
+ function createGitHubWebhookRoute(args) {
1248
+ return {
1249
+ method: "POST",
1250
+ path: "/api/webhooks/github",
1251
+ async handler(request) {
1252
+ const rawBody = await request.text();
1253
+ const signature = request.headers.get("x-hub-signature-256") ?? "";
1254
+ if (!verifyGitHubSignature(rawBody, signature, args.webhookSecret())) {
1255
+ return new Response("Unauthorized", { status: 401 });
1256
+ }
1257
+ const deliveryId = request.headers.get("x-github-delivery");
1258
+ const eventName = request.headers.get("x-github-event");
1259
+ if (!deliveryId || !eventName) {
1260
+ return new Response("Malformed GitHub webhook", { status: 400 });
1261
+ }
1262
+ const body = parseJson(rawBody);
1263
+ const outcome = eventName === "pull_request" ? normalizeGitHubPullRequestOutcome({
1264
+ body,
1265
+ botEmail: args.botEmail()
1266
+ }) : void 0;
1267
+ if (outcome) {
1268
+ await recordGitHubPullRequestOutcome(args.db, outcome);
1269
+ }
1270
+ const resourceEvents = normalizeGitHubResourceEvents({
1271
+ body,
1272
+ deliveryId,
1273
+ eventName
1274
+ });
1275
+ for (const event of resourceEvents) {
1276
+ await args.resourceEvents.publish(event);
1277
+ }
1278
+ if (!outcome && resourceEvents.length === 0) {
1279
+ return new Response("Ignored", { status: 202 });
1280
+ }
1281
+ return new Response("Accepted", { status: 202 });
1282
+ }
1283
+ };
1284
+ }
1285
+
1286
+ // src/pull-request-outcomes/report.ts
1287
+ import { sql as sql2 } from "drizzle-orm";
1288
+ import { z as z8 } from "zod";
1289
+ var DAY_MS = 24 * 60 * 60 * 1e3;
1290
+ var WINDOWS = [7, 30, 90];
1291
+ var outcomeStatsSchema = z8.object({
1292
+ closed: z8.number().int().nonnegative(),
1293
+ created: z8.number().int().nonnegative(),
1294
+ days: z8.number().int().positive(),
1295
+ medianMergeTimeMs: z8.number().nonnegative().nullable(),
1296
+ merged: z8.number().int().nonnegative(),
1297
+ open: z8.number().int().nonnegative()
1298
+ }).strict().transform((row) => {
1299
+ const terminal = row.merged + row.closed;
1300
+ return {
1301
+ ...row,
1302
+ medianMergeTimeMs: row.medianMergeTimeMs ?? void 0,
1303
+ mergeRate: terminal > 0 ? row.merged / terminal : void 0
1304
+ };
1305
+ });
1306
+ var repositoryStatsSchema = z8.object({
1307
+ closed: z8.number().int().nonnegative(),
1308
+ created: z8.number().int().nonnegative(),
1309
+ merged: z8.number().int().nonnegative(),
1310
+ open: z8.number().int().nonnegative(),
1311
+ repository: z8.string().min(1)
1312
+ }).strict().transform((row) => {
1313
+ const terminal = row.merged + row.closed;
1314
+ return {
1315
+ ...row,
1316
+ mergeRate: terminal > 0 ? row.merged / terminal : void 0
1317
+ };
1318
+ });
1319
+ function queryRows(result) {
1320
+ if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
1321
+ throw new TypeError("GitHub outcome query did not return rows");
1322
+ }
1323
+ return result.rows;
1324
+ }
1325
+ async function aggregateOutcomeWindows(args) {
1326
+ const starts = WINDOWS.map(
1327
+ (days) => [days, new Date(args.nowMs - days * DAY_MS)]
1328
+ );
1329
+ const oldestStart = starts.at(-1)[1];
1330
+ const table = juniorGitHubPullRequests;
1331
+ const result = await args.db.execute(sql2`
1332
+ WITH windows(days, start_at) AS (
1333
+ VALUES
1334
+ (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
1335
+ (${starts[1][0]}::integer, ${starts[1][1]}::timestamptz),
1336
+ (${starts[2][0]}::integer, ${starts[2][1]}::timestamptz)
1337
+ ), recent_pull_requests AS MATERIALIZED (
1338
+ SELECT
1339
+ ${table.pullRequestId},
1340
+ ${table.state},
1341
+ ${table.openedAt},
1342
+ ${table.mergedAt},
1343
+ ${table.closedAt}
1344
+ FROM ${table}
1345
+ WHERE ${table.state} = 'open'
1346
+ OR ${table.openedAt} >= ${oldestStart}
1347
+ OR ${table.mergedAt} >= ${oldestStart}
1348
+ OR ${table.closedAt} >= ${oldestStart}
1349
+ )
1350
+ SELECT
1351
+ windows.days AS "days",
1352
+ count(recent_pull_requests.pull_request_id)
1353
+ FILTER (WHERE recent_pull_requests.opened_at >= windows.start_at)::integer
1354
+ AS "created",
1355
+ count(recent_pull_requests.pull_request_id)
1356
+ FILTER (
1357
+ WHERE recent_pull_requests.state = 'merged'
1358
+ AND recent_pull_requests.merged_at >= windows.start_at
1359
+ )::integer AS "merged",
1360
+ count(recent_pull_requests.pull_request_id)
1361
+ FILTER (
1362
+ WHERE recent_pull_requests.state = 'closed_unmerged'
1363
+ AND recent_pull_requests.closed_at >= windows.start_at
1364
+ )::integer AS "closed",
1365
+ count(recent_pull_requests.pull_request_id)
1366
+ FILTER (WHERE recent_pull_requests.state = 'open')::integer AS "open",
1367
+ (
1368
+ percentile_cont(0.5) WITHIN GROUP (
1369
+ ORDER BY extract(
1370
+ epoch FROM (
1371
+ recent_pull_requests.merged_at - recent_pull_requests.opened_at
1372
+ )
1373
+ ) * 1000
1374
+ ) FILTER (
1375
+ WHERE recent_pull_requests.state = 'merged'
1376
+ AND recent_pull_requests.merged_at >= windows.start_at
1377
+ )
1378
+ )::double precision AS "medianMergeTimeMs"
1379
+ FROM windows
1380
+ LEFT JOIN recent_pull_requests ON true
1381
+ GROUP BY windows.days
1382
+ ORDER BY windows.days
1383
+ `);
1384
+ return z8.array(outcomeStatsSchema).parse(queryRows(result));
1385
+ }
1386
+ async function aggregateRepositories(args) {
1387
+ const start = new Date(args.nowMs - 30 * DAY_MS);
1388
+ const table = juniorGitHubPullRequests;
1389
+ const result = await args.db.execute(sql2`
1390
+ SELECT
1391
+ ${table.repositoryFullName} AS "repository",
1392
+ count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
1393
+ AS "created",
1394
+ count(*) FILTER (
1395
+ WHERE ${table.state} = 'merged' AND ${table.mergedAt} >= ${start}
1396
+ )::integer AS "merged",
1397
+ count(*) FILTER (
1398
+ WHERE ${table.state} = 'closed_unmerged'
1399
+ AND ${table.closedAt} >= ${start}
1400
+ )::integer AS "closed",
1401
+ count(*) FILTER (WHERE ${table.state} = 'open')::integer AS "open"
1402
+ FROM ${table}
1403
+ WHERE ${table.state} = 'open'
1404
+ OR ${table.openedAt} >= ${start}
1405
+ OR ${table.mergedAt} >= ${start}
1406
+ OR ${table.closedAt} >= ${start}
1407
+ GROUP BY ${table.repositoryFullName}
1408
+ ORDER BY "merged" DESC, "created" DESC, "repository" ASC
1409
+ LIMIT 25
1410
+ `);
1411
+ return z8.array(repositoryStatsSchema).parse(queryRows(result));
1412
+ }
1413
+ function formatPercent(value) {
1414
+ return value === void 0 ? "\u2014" : `${Math.round(value * 100)}%`;
1415
+ }
1416
+ function formatDuration(value) {
1417
+ if (value === void 0) return "\u2014";
1418
+ const hours = value / (60 * 60 * 1e3);
1419
+ if (hours < 1) return `${Math.round(hours * 60)}m`;
1420
+ if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
1421
+ return `${Math.round(hours / 24 * 10) / 10}d`;
1422
+ }
1423
+ async function buildGitHubOutcomeReport(args) {
1424
+ const [windows, repositories] = await Promise.all([
1425
+ aggregateOutcomeWindows(args),
1426
+ aggregateRepositories(args)
1427
+ ]);
1428
+ const thirtyDays = windows.find((window) => window.days === 30);
1429
+ return {
1430
+ generatedAt: new Date(args.nowMs).toISOString(),
1431
+ title: "GitHub work delivered",
1432
+ metrics: [
1433
+ { label: "created \xB7 30d", value: String(thirtyDays.created) },
1434
+ {
1435
+ label: "merged \xB7 30d",
1436
+ tone: thirtyDays.merged > 0 ? "good" : "neutral",
1437
+ value: String(thirtyDays.merged)
1438
+ },
1439
+ { label: "closed unmerged \xB7 30d", value: String(thirtyDays.closed) },
1440
+ { label: "open now", value: String(thirtyDays.open) },
1441
+ { label: "merge rate \xB7 30d", value: formatPercent(thirtyDays.mergeRate) },
1442
+ {
1443
+ label: "median merge time \xB7 30d",
1444
+ value: formatDuration(thirtyDays.medianMergeTimeMs)
1445
+ }
1446
+ ],
1447
+ recordSets: [
1448
+ {
1449
+ title: "Outcome windows",
1450
+ fields: [
1451
+ { key: "window", label: "Window" },
1452
+ { key: "created", label: "Created" },
1453
+ { key: "merged", label: "Merged" },
1454
+ { key: "closed", label: "Closed unmerged" },
1455
+ { key: "mergeRate", label: "Merge rate" },
1456
+ { key: "mergeTime", label: "Median merge time" }
1457
+ ],
1458
+ records: windows.map((stats) => ({
1459
+ id: `${stats.days}d`,
1460
+ values: {
1461
+ window: `${stats.days} days`,
1462
+ created: String(stats.created),
1463
+ merged: String(stats.merged),
1464
+ closed: String(stats.closed),
1465
+ mergeRate: formatPercent(stats.mergeRate),
1466
+ mergeTime: formatDuration(stats.medianMergeTimeMs)
1467
+ }
1468
+ }))
1469
+ },
1470
+ {
1471
+ title: "Repositories \xB7 30d",
1472
+ emptyText: "No Junior-owned pull request activity yet.",
1473
+ fields: [
1474
+ { key: "repository", label: "Repository" },
1475
+ { key: "created", label: "Created" },
1476
+ { key: "merged", label: "Merged" },
1477
+ { key: "closed", label: "Closed unmerged" },
1478
+ { key: "open", label: "Open now" },
1479
+ { key: "mergeRate", label: "Merge rate" }
1480
+ ],
1481
+ records: repositories.map(({ repository, ...stats }) => ({
1482
+ id: repository,
1483
+ values: {
1484
+ repository,
1485
+ created: String(stats.created),
1486
+ merged: String(stats.merged),
1487
+ closed: String(stats.closed),
1488
+ open: String(stats.open),
1489
+ mergeRate: formatPercent(stats.mergeRate)
1490
+ }
1491
+ }))
1492
+ }
1493
+ ]
944
1494
  };
945
1495
  }
946
1496
 
@@ -1281,11 +1831,11 @@ async function githubRequest(apiBase, path, params) {
1281
1831
  },
1282
1832
  ...params.body ? { body: JSON.stringify(params.body) } : {}
1283
1833
  });
1284
- const text = await response.text();
1834
+ const text2 = await response.text();
1285
1835
  let parsed;
1286
- if (text) {
1836
+ if (text2) {
1287
1837
  try {
1288
- parsed = JSON.parse(text);
1838
+ parsed = JSON.parse(text2);
1289
1839
  } catch {
1290
1840
  parsed = void 0;
1291
1841
  }
@@ -2171,6 +2721,22 @@ function githubPlugin(options = {}) {
2171
2721
  ]
2172
2722
  },
2173
2723
  hooks: {
2724
+ routes(ctx) {
2725
+ return [
2726
+ createGitHubWebhookRoute({
2727
+ botEmail: () => readEnv(botEmailEnv),
2728
+ db: ctx.db,
2729
+ resourceEvents: ctx.resourceEvents,
2730
+ webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET")
2731
+ })
2732
+ ];
2733
+ },
2734
+ async operationalReport(ctx) {
2735
+ return await buildGitHubOutcomeReport({
2736
+ db: ctx.db,
2737
+ nowMs: ctx.nowMs
2738
+ });
2739
+ },
2174
2740
  tools(ctx) {
2175
2741
  return createGitHubTools(ctx);
2176
2742
  },