@sentry/junior-github 0.105.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) {
@@ -472,6 +477,7 @@ import {
472
477
  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";
480
+ import { subscribableResourceSchema } from "@sentry/junior-plugin-api";
475
481
  var GITHUB_PULL_REQUEST_CREATE_IDEMPOTENCY_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
476
482
  var GITHUB_PULL_REQUEST_CREATE_LOCK_TTL_MS = 6e4;
477
483
  var GitHubPullRequestCreateRejectedError = class extends Error {
@@ -537,14 +543,6 @@ var createPullRequestStateSchema = Type2.Union([
537
543
  { additionalProperties: false }
538
544
  )
539
545
  ]);
540
- var subscribableResourceSchema = z2.object({
541
- provider: z2.string(),
542
- type: z2.string(),
543
- resourceRef: z2.string(),
544
- label: z2.string(),
545
- supportedEvents: z2.array(z2.string()),
546
- suggestedEvents: z2.array(z2.string()).optional()
547
- }).strict();
548
546
  var gitHubPullRequestDataSchema = z2.object({
549
547
  number: z2.number(),
550
548
  url: z2.string(),
@@ -586,14 +584,14 @@ function parseRepo2(value) {
586
584
  };
587
585
  }
588
586
  async function readJsonResponse2(response) {
589
- const text = await response.text();
590
- if (!text) {
587
+ const text2 = await response.text();
588
+ if (!text2) {
591
589
  return void 0;
592
590
  }
593
591
  try {
594
- return JSON.parse(text);
592
+ return JSON.parse(text2);
595
593
  } catch {
596
- return text;
594
+ return text2;
597
595
  }
598
596
  }
599
597
  function githubApiErrorMessage2(payload) {
@@ -706,45 +704,13 @@ async function createGitHubPullRequest(ctx, request) {
706
704
  url: pullRequest.html_url
707
705
  };
708
706
  }
709
- function gitHubPullRequestSubscribable(input, result) {
710
- const repo = parseRepo2(input.repo);
711
- const repoRef = `${repo.owner}/${repo.name}`;
712
- const supportedEvents = [
713
- "checks.failed",
714
- "checks.recovered",
715
- "comment.created",
716
- "review.approved",
717
- "review.changes_requested",
718
- "review.commented",
719
- "review_comment.created",
720
- "state.merged",
721
- "state.closed_unmerged"
722
- ];
723
- return {
724
- label: `GitHub PR ${repoRef}#${result.number}`,
725
- provider: "github",
726
- resourceRef: `github:pull_request:${repoRef}#${result.number}`,
727
- suggestedEvents: [
728
- "checks.failed",
729
- "comment.created",
730
- "review.changes_requested",
731
- "review.commented",
732
- "review_comment.created",
733
- "state.merged",
734
- "state.closed_unmerged"
735
- ],
736
- supportedEvents,
737
- type: "pull_request"
738
- };
739
- }
740
707
  function gitHubPullRequestToolResult(input, result) {
741
- if (!process.env.GITHUB_WEBHOOK_SECRET?.trim()) {
742
- return result;
743
- }
744
- return {
745
- ...result,
746
- subscribable: gitHubPullRequestSubscribable(input, result)
747
- };
708
+ const repo = parseRepo2(input.repo);
709
+ const subscribable = gitHubPullRequestSubscribable({
710
+ number: result.number,
711
+ repo: `${repo.owner}/${repo.name}`
712
+ });
713
+ return { ...result, ...subscribable ? { subscribable } : {} };
748
714
  }
749
715
  function gitHubPullRequestStructuredResult(input, result) {
750
716
  const data = gitHubPullRequestToolResult(input, result);
@@ -832,11 +798,699 @@ function createGitHubPullRequestTool(ctx) {
832
798
  });
833
799
  }
834
800
 
801
+ // src/tools/get-pull-request.ts
802
+ import {
803
+ definePluginTool as definePluginTool3,
804
+ PluginToolInputError as PluginToolInputError4,
805
+ pluginToolResultSchema as pluginToolResultSchema3
806
+ } from "@sentry/junior-plugin-api";
807
+ import { z as z3 } from "zod";
808
+ import { subscribableResourceSchema as subscribableResourceSchema2 } from "@sentry/junior-plugin-api";
809
+ var inputSchema = z3.object({
810
+ repo: z3.string().describe('Repository in "owner/name" format.'),
811
+ number: z3.number().int().positive().describe("Pull request number.")
812
+ }).strict();
813
+ var pullRequestSchema = z3.object({
814
+ base: z3.string(),
815
+ draft: z3.boolean(),
816
+ head: z3.string(),
817
+ merged: z3.boolean(),
818
+ number: z3.number(),
819
+ state: z3.string(),
820
+ subscribable: subscribableResourceSchema2.optional(),
821
+ title: z3.string(),
822
+ url: z3.string()
823
+ });
824
+ var outputSchema = pluginToolResultSchema3.extend({
825
+ ok: z3.literal(true),
826
+ status: z3.literal("success"),
827
+ target: z3.literal("getPullRequest"),
828
+ data: pullRequestSchema,
829
+ ...pullRequestSchema.shape
830
+ });
831
+ function parseRepo3(value) {
832
+ const parts = value.split("/").map((part) => part.trim());
833
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
834
+ throw new PluginToolInputError4('repo must use "owner/name" format');
835
+ }
836
+ return { owner: parts[0], name: parts[1], ref: `${parts[0]}/${parts[1]}` };
837
+ }
838
+ async function readJson(response) {
839
+ const text2 = await response.text();
840
+ if (!text2) return void 0;
841
+ try {
842
+ return JSON.parse(text2);
843
+ } catch {
844
+ return text2;
845
+ }
846
+ }
847
+ function createGitHubGetPullRequestTool(ctx) {
848
+ return definePluginTool3({
849
+ description: "Get a GitHub pull request. Use this when an existing PR may need resource-event monitoring; the result includes a subscribable hint when GitHub webhooks are configured.",
850
+ inputSchema,
851
+ outputSchema,
852
+ async execute(input) {
853
+ const repo = parseRepo3(input.repo);
854
+ const response = await ctx.egress.fetch({
855
+ provider: "github",
856
+ operation: "github.pull.get",
857
+ request: new Request(
858
+ `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/pulls/${input.number}`,
859
+ {
860
+ headers: {
861
+ Accept: "application/vnd.github+json",
862
+ "X-GitHub-Api-Version": "2022-11-28"
863
+ }
864
+ }
865
+ )
866
+ });
867
+ const parsed = await readJson(response);
868
+ if (!response.ok)
869
+ throw new Error(
870
+ `GitHub pull request lookup failed with HTTP ${response.status}`
871
+ );
872
+ const providerResult = z3.object({
873
+ base: z3.object({ ref: z3.string() }),
874
+ draft: z3.boolean(),
875
+ head: z3.object({ ref: z3.string() }),
876
+ html_url: z3.string(),
877
+ merged: z3.boolean().optional().default(false),
878
+ number: z3.number(),
879
+ state: z3.string(),
880
+ title: z3.string()
881
+ }).parse(parsed);
882
+ const subscribable = gitHubPullRequestSubscribable({
883
+ number: providerResult.number,
884
+ repo: repo.ref
885
+ });
886
+ const data = {
887
+ base: providerResult.base.ref,
888
+ draft: providerResult.draft,
889
+ head: providerResult.head.ref,
890
+ merged: providerResult.merged,
891
+ number: providerResult.number,
892
+ state: providerResult.state,
893
+ ...subscribable ? { subscribable } : {},
894
+ title: providerResult.title,
895
+ url: providerResult.html_url
896
+ };
897
+ return {
898
+ ok: true,
899
+ status: "success",
900
+ target: "getPullRequest",
901
+ data,
902
+ ...data
903
+ };
904
+ }
905
+ });
906
+ }
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
+
835
1059
  // src/tools.ts
836
1060
  function createGitHubTools(ctx) {
837
1061
  return {
838
1062
  createIssue: createGitHubIssueTool(ctx),
839
- createPullRequest: createGitHubPullRequestTool(ctx)
1063
+ createPullRequest: createGitHubPullRequestTool(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
+ ]
840
1494
  };
841
1495
  }
842
1496
 
@@ -1177,11 +1831,11 @@ async function githubRequest(apiBase, path, params) {
1177
1831
  },
1178
1832
  ...params.body ? { body: JSON.stringify(params.body) } : {}
1179
1833
  });
1180
- const text = await response.text();
1834
+ const text2 = await response.text();
1181
1835
  let parsed;
1182
- if (text) {
1836
+ if (text2) {
1183
1837
  try {
1184
- parsed = JSON.parse(text);
1838
+ parsed = JSON.parse(text2);
1185
1839
  } catch {
1186
1840
  parsed = void 0;
1187
1841
  }
@@ -2067,6 +2721,22 @@ function githubPlugin(options = {}) {
2067
2721
  ]
2068
2722
  },
2069
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
+ },
2070
2740
  tools(ctx) {
2071
2741
  return createGitHubTools(ctx);
2072
2742
  },