@sentry/junior-github 0.107.0 → 0.108.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
@@ -90,6 +90,7 @@ import { z } from "zod";
90
90
  import { PluginToolInputError } from "@sentry/junior-plugin-api";
91
91
  var GITHUB_SESSION_FOOTER_START = "<!-- junior-session-footer:start -->";
92
92
  var GITHUB_SESSION_FOOTER_END = "<!-- junior-session-footer:end -->";
93
+ var GITHUB_CONVERSATION_ID_MARKER = "junior-conversation-id:";
93
94
  function nonEmptyString(value, name) {
94
95
  if (!value?.trim()) {
95
96
  throw new PluginToolInputError(`${name} is required`);
@@ -132,7 +133,9 @@ function githubConversationFooter(conversationId, dashboardUrl) {
132
133
  return void 0;
133
134
  }
134
135
  const label = normalizedDashboardUrl ? "View Junior Session" : "View Junior Session in Sentry";
136
+ const conversationMarker = `<!-- ${GITHUB_CONVERSATION_ID_MARKER}${encodeURIComponent(id)} -->`;
135
137
  return `${GITHUB_SESSION_FOOTER_START}
138
+ ${conversationMarker}
136
139
 
137
140
  --
138
141
 
@@ -140,6 +143,29 @@ function githubConversationFooter(conversationId, dashboardUrl) {
140
143
 
141
144
  ${GITHUB_SESSION_FOOTER_END}`;
142
145
  }
146
+ function githubConversationIds(body) {
147
+ if (!body) return [];
148
+ const ids = /* @__PURE__ */ new Set();
149
+ const footer = new RegExp(
150
+ `${escapeRegExp(GITHUB_SESSION_FOOTER_START)}[\\s\\S]*?${escapeRegExp(GITHUB_SESSION_FOOTER_END)}`,
151
+ "g"
152
+ );
153
+ const marker = new RegExp(
154
+ `<!--\\s*${escapeRegExp(GITHUB_CONVERSATION_ID_MARKER)}([^\\s]+)\\s*-->`,
155
+ "g"
156
+ );
157
+ for (const footerMatch of body.matchAll(footer)) {
158
+ for (const markerMatch of footerMatch[0].matchAll(marker)) {
159
+ try {
160
+ const id = decodeURIComponent(markerMatch[1] ?? "").trim();
161
+ if (id) ids.add(id);
162
+ } catch {
163
+ continue;
164
+ }
165
+ }
166
+ }
167
+ return [...ids];
168
+ }
143
169
  function appendGitHubFooter(body, conversationId, dashboardUrl) {
144
170
  const footer = githubConversationFooter(conversationId, dashboardUrl);
145
171
  const normalizedBody = body.trimEnd();
@@ -1069,7 +1095,7 @@ function createGitHubTools(ctx) {
1069
1095
  // src/webhooks/handler.ts
1070
1096
  import { createHmac, timingSafeEqual } from "crypto";
1071
1097
 
1072
- // src/pull-request-outcomes/store.ts
1098
+ // src/issue-outcomes/store.ts
1073
1099
  import { and, eq, lte } from "drizzle-orm";
1074
1100
  import { z as z6 } from "zod";
1075
1101
 
@@ -1082,6 +1108,29 @@ var githubPullRequestStateSchema = z5.enum([
1082
1108
  "merged",
1083
1109
  "open"
1084
1110
  ]);
1111
+ var githubPullRequestCommitCompositionSchema = z5.enum([
1112
+ "junior_only",
1113
+ "mixed"
1114
+ ]);
1115
+ var githubIssueStateSchema = z5.enum(["closed", "open"]);
1116
+ var juniorGitHubIssues = pgTable(
1117
+ "junior_github_issues",
1118
+ {
1119
+ issueId: text("issue_id").primaryKey(),
1120
+ repositoryId: text("repository_id").notNull(),
1121
+ repositoryFullName: text("repository_full_name").notNull(),
1122
+ number: integer("number").notNull(),
1123
+ state: text("state").$type().notNull(),
1124
+ openedAt: timestamp("opened_at", { withTimezone: true }).notNull(),
1125
+ closedAt: timestamp("closed_at", { withTimezone: true }),
1126
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull()
1127
+ },
1128
+ (table) => [
1129
+ index("junior_github_issues_opened_at_idx").on(table.openedAt),
1130
+ index("junior_github_issues_closed_at_idx").on(table.closedAt),
1131
+ index("junior_github_issues_open_idx").on(table.issueId).where(sql`${table.state} = 'open'`)
1132
+ ]
1133
+ );
1085
1134
  var juniorGitHubPullRequests = pgTable(
1086
1135
  "junior_github_pull_requests",
1087
1136
  {
@@ -1090,6 +1139,8 @@ var juniorGitHubPullRequests = pgTable(
1090
1139
  repositoryFullName: text("repository_full_name").notNull(),
1091
1140
  number: integer("number").notNull(),
1092
1141
  state: text("state").$type().notNull(),
1142
+ commitComposition: text("commit_composition").$type(),
1143
+ conversationIds: text("conversation_ids").array().notNull().default(sql`ARRAY[]::text[]`),
1093
1144
  openedAt: timestamp("opened_at", { withTimezone: true }).notNull(),
1094
1145
  mergedAt: timestamp("merged_at", { withTimezone: true }),
1095
1146
  closedAt: timestamp("closed_at", { withTimezone: true }),
@@ -1103,23 +1154,21 @@ var juniorGitHubPullRequests = pgTable(
1103
1154
  ]
1104
1155
  );
1105
1156
 
1106
- // src/pull-request-outcomes/store.ts
1107
- var githubPullRequestOutcomeInputSchema = z6.object({
1157
+ // src/issue-outcomes/store.ts
1158
+ var githubIssueOutcomeInputSchema = z6.object({
1108
1159
  candidateOwned: z6.boolean(),
1109
1160
  closedAt: z6.date().optional(),
1110
- mergedAt: z6.date().optional(),
1161
+ issueId: z6.string().min(1),
1111
1162
  number: z6.number().int().positive(),
1112
1163
  openedAt: z6.date(),
1113
- pullRequestId: z6.string().min(1),
1114
1164
  repositoryFullName: z6.string().min(1),
1115
1165
  repositoryId: z6.string().min(1),
1116
- state: githubPullRequestStateSchema,
1166
+ state: githubIssueStateSchema,
1117
1167
  updatedAt: z6.date()
1118
1168
  }).strict();
1119
1169
  function projectionValues(input) {
1120
1170
  return {
1121
1171
  closedAt: input.closedAt ?? null,
1122
- mergedAt: input.mergedAt ?? null,
1123
1172
  number: input.number,
1124
1173
  openedAt: input.openedAt,
1125
1174
  repositoryFullName: input.repositoryFullName,
@@ -1128,46 +1177,112 @@ function projectionValues(input) {
1128
1177
  updatedAt: input.updatedAt
1129
1178
  };
1130
1179
  }
1131
- async function recordGitHubPullRequestOutcome(db, input) {
1132
- const outcome = githubPullRequestOutcomeInputSchema.parse(input);
1180
+ async function recordGitHubIssueOutcome(db, input) {
1181
+ const outcome = githubIssueOutcomeInputSchema.parse(input);
1133
1182
  const values = projectionValues(outcome);
1134
1183
  if (!outcome.candidateOwned) {
1135
- await db.update(juniorGitHubPullRequests).set(values).where(
1184
+ await db.update(juniorGitHubIssues).set(values).where(
1136
1185
  and(
1137
- eq(juniorGitHubPullRequests.pullRequestId, outcome.pullRequestId),
1138
- lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1186
+ eq(juniorGitHubIssues.issueId, outcome.issueId),
1187
+ lte(juniorGitHubIssues.updatedAt, outcome.updatedAt)
1139
1188
  )
1140
1189
  );
1141
1190
  return;
1142
1191
  }
1143
- await db.insert(juniorGitHubPullRequests).values({ pullRequestId: outcome.pullRequestId, ...values }).onConflictDoUpdate({
1144
- target: juniorGitHubPullRequests.pullRequestId,
1192
+ await db.insert(juniorGitHubIssues).values({ issueId: outcome.issueId, ...values }).onConflictDoUpdate({
1193
+ target: juniorGitHubIssues.issueId,
1145
1194
  set: values,
1146
- where: lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1195
+ where: lte(juniorGitHubIssues.updatedAt, outcome.updatedAt)
1147
1196
  });
1148
1197
  }
1149
1198
 
1150
- // src/webhooks/pull-request-outcome.ts
1199
+ // src/pull-request-outcomes/store.ts
1200
+ import { and as and2, eq as eq2, lte as lte2, sql as sql2 } from "drizzle-orm";
1151
1201
  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() });
1202
+ var githubPullRequestOutcomeInputSchema = z7.object({
1203
+ candidateOwned: z7.boolean(),
1204
+ closedAt: z7.date().optional(),
1205
+ commitComposition: githubPullRequestCommitCompositionSchema.optional(),
1206
+ mergedAt: z7.date().optional(),
1207
+ number: z7.number().int().positive(),
1208
+ openedAt: z7.date(),
1209
+ pullRequestId: z7.string().min(1),
1210
+ repositoryFullName: z7.string().min(1),
1211
+ repositoryId: z7.string().min(1),
1212
+ state: githubPullRequestStateSchema,
1213
+ updatedAt: z7.date()
1214
+ }).strict();
1215
+ var githubPullRequestConversationsInputSchema = z7.object({
1216
+ conversationIds: z7.array(z7.string().min(1)).min(1),
1217
+ pullRequestId: z7.string().min(1)
1218
+ }).strict();
1219
+ function projectionValues2(input) {
1220
+ return {
1221
+ closedAt: input.closedAt ?? null,
1222
+ ...input.commitComposition ? { commitComposition: input.commitComposition } : {},
1223
+ mergedAt: input.mergedAt ?? null,
1224
+ number: input.number,
1225
+ openedAt: input.openedAt,
1226
+ repositoryFullName: input.repositoryFullName,
1227
+ repositoryId: input.repositoryId,
1228
+ state: input.state,
1229
+ updatedAt: input.updatedAt
1230
+ };
1231
+ }
1232
+ async function recordGitHubPullRequestOutcome(db, input) {
1233
+ const outcome = githubPullRequestOutcomeInputSchema.parse(input);
1234
+ const values = projectionValues2(outcome);
1235
+ if (!outcome.candidateOwned) {
1236
+ const updated = await db.update(juniorGitHubPullRequests).set(values).where(
1237
+ and2(
1238
+ eq2(juniorGitHubPullRequests.pullRequestId, outcome.pullRequestId),
1239
+ lte2(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1240
+ )
1241
+ ).returning({
1242
+ commitComposition: juniorGitHubPullRequests.commitComposition
1243
+ });
1244
+ return {
1245
+ applied: updated.length > 0,
1246
+ commitComposition: updated[0]?.commitComposition ?? void 0
1247
+ };
1248
+ }
1249
+ const inserted = await db.insert(juniorGitHubPullRequests).values({ pullRequestId: outcome.pullRequestId, ...values }).onConflictDoUpdate({
1250
+ target: juniorGitHubPullRequests.pullRequestId,
1251
+ set: values,
1252
+ where: lte2(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1253
+ }).returning({
1254
+ commitComposition: juniorGitHubPullRequests.commitComposition
1255
+ });
1256
+ return {
1257
+ applied: inserted.length > 0,
1258
+ commitComposition: inserted[0]?.commitComposition ?? void 0
1259
+ };
1260
+ }
1261
+ async function recordGitHubPullRequestConversations(db, input) {
1262
+ const association = githubPullRequestConversationsInputSchema.parse(input);
1263
+ const conversationIds = sql2.join(
1264
+ association.conversationIds.map((id) => sql2`${id}`),
1265
+ sql2`, `
1266
+ );
1267
+ const updated = await db.update(juniorGitHubPullRequests).set({
1268
+ conversationIds: sql2`ARRAY(
1269
+ SELECT DISTINCT value
1270
+ FROM unnest(
1271
+ ${juniorGitHubPullRequests.conversationIds}
1272
+ || ARRAY[${conversationIds}]::text[]
1273
+ ) AS value
1274
+ ORDER BY value
1275
+ )`
1276
+ }).where(
1277
+ eq2(juniorGitHubPullRequests.pullRequestId, association.pullRequestId)
1278
+ ).returning({ pullRequestId: juniorGitHubPullRequests.pullRequestId });
1279
+ return updated.length > 0;
1280
+ }
1281
+
1282
+ // src/webhooks/issue-outcome.ts
1283
+ import { z as z8 } from "zod";
1284
+
1285
+ // src/webhooks/ownership.ts
1171
1286
  var GITHUB_NOREPLY_DOMAIN = "users.noreply.github.com";
1172
1287
  function botLoginFromEmail(value) {
1173
1288
  const email = value?.trim();
@@ -1180,11 +1295,191 @@ function botLoginFromEmail(value) {
1180
1295
  const login = localPart.slice(localPart.indexOf("+") + 1).trim();
1181
1296
  return login.toLowerCase().endsWith("[bot]") ? login : void 0;
1182
1297
  }
1298
+
1299
+ // src/webhooks/issue-outcome.ts
1300
+ var canonicalIssueOutcomeSchema = z8.object({
1301
+ action: z8.enum(["opened", "closed", "reopened"]),
1302
+ issue: z8.object({
1303
+ body: z8.string().nullable().optional(),
1304
+ closed_at: z8.string().nullable().optional(),
1305
+ created_at: z8.string(),
1306
+ id: z8.number().int().positive(),
1307
+ number: z8.number().int().positive(),
1308
+ updated_at: z8.string(),
1309
+ user: z8.object({ login: z8.string().min(1) }).strict()
1310
+ }).strict(),
1311
+ repository: z8.object({
1312
+ full_name: z8.string().min(1),
1313
+ id: z8.number().int().positive()
1314
+ }).strict()
1315
+ }).strict();
1316
+ var issueOutcomeSchema = z8.object({
1317
+ action: z8.enum(["opened", "closed", "reopened"]),
1318
+ issue: z8.object({
1319
+ body: z8.string().nullable().optional(),
1320
+ closed_at: z8.string().nullable().optional(),
1321
+ created_at: z8.string(),
1322
+ id: z8.number().int().positive(),
1323
+ number: z8.number().int().positive(),
1324
+ updated_at: z8.string(),
1325
+ user: z8.object({ login: z8.string().min(1) }).passthrough()
1326
+ }).passthrough(),
1327
+ repository: z8.object({
1328
+ full_name: z8.string().min(1),
1329
+ id: z8.number().int().positive()
1330
+ }).passthrough()
1331
+ }).passthrough().transform(
1332
+ (provider) => canonicalIssueOutcomeSchema.parse({
1333
+ action: provider.action,
1334
+ issue: {
1335
+ body: provider.issue.body,
1336
+ closed_at: provider.issue.closed_at,
1337
+ created_at: provider.issue.created_at,
1338
+ id: provider.issue.id,
1339
+ number: provider.issue.number,
1340
+ updated_at: provider.issue.updated_at,
1341
+ user: { login: provider.issue.user.login }
1342
+ },
1343
+ repository: {
1344
+ full_name: provider.repository.full_name,
1345
+ id: provider.repository.id
1346
+ }
1347
+ })
1348
+ );
1349
+ var issueLifecycleActionSchema = z8.object({ action: z8.string() }).passthrough();
1183
1350
  function timestamp2(value) {
1184
1351
  if (!value) return void 0;
1185
1352
  const parsed = new Date(value);
1186
1353
  return Number.isFinite(parsed.getTime()) ? parsed : void 0;
1187
1354
  }
1355
+ function normalizeGitHubIssueOutcome(args) {
1356
+ const lifecycle = issueLifecycleActionSchema.safeParse(args.body);
1357
+ if (!lifecycle.success || !["opened", "closed", "reopened"].includes(lifecycle.data.action)) {
1358
+ return void 0;
1359
+ }
1360
+ const parsed = issueOutcomeSchema.parse(args.body);
1361
+ const issue = parsed.issue;
1362
+ const openedAt = timestamp2(issue.created_at);
1363
+ const updatedAt = timestamp2(issue.updated_at);
1364
+ if (!openedAt || !updatedAt) {
1365
+ throw new Error("GitHub issue lifecycle timestamps are invalid");
1366
+ }
1367
+ const authorLogin = issue.user.login.trim().toLowerCase();
1368
+ const botLogin = botLoginFromEmail(args.botEmail)?.toLowerCase();
1369
+ if (parsed.action === "opened" && !botLogin) {
1370
+ throw new Error(
1371
+ "The configured GitHub App bot email must encode a [bot] login in GitHub's noreply format to classify issue ownership"
1372
+ );
1373
+ }
1374
+ const hasOwnershipMarker = Boolean(
1375
+ botLogin && authorLogin === botLogin && issue.body?.includes(GITHUB_SESSION_FOOTER_START)
1376
+ );
1377
+ const candidateOwned = hasOwnershipMarker && (parsed.action === "opened" || parsed.action === "closed");
1378
+ const closedAt = timestamp2(issue.closed_at);
1379
+ if (parsed.action === "closed" && !closedAt) {
1380
+ throw new Error("GitHub issue terminal timestamp is invalid");
1381
+ }
1382
+ return {
1383
+ candidateOwned,
1384
+ closedAt,
1385
+ issueId: String(issue.id),
1386
+ number: issue.number,
1387
+ openedAt,
1388
+ repositoryFullName: parsed.repository.full_name,
1389
+ repositoryId: String(parsed.repository.id),
1390
+ state: parsed.action === "closed" ? "closed" : "open",
1391
+ updatedAt
1392
+ };
1393
+ }
1394
+
1395
+ // src/webhooks/pull-request-outcome.ts
1396
+ import { z as z9 } from "zod";
1397
+ var canonicalPullRequestOutcomeSchema = z9.object({
1398
+ action: z9.enum(["opened", "closed", "reopened"]),
1399
+ pull_request: z9.object({
1400
+ body: z9.string().nullable().optional(),
1401
+ closed_at: z9.string().nullable().optional(),
1402
+ created_at: z9.string(),
1403
+ id: z9.number().int().positive(),
1404
+ merged: z9.boolean(),
1405
+ merged_at: z9.string().nullable().optional(),
1406
+ number: z9.number().int().positive(),
1407
+ updated_at: z9.string(),
1408
+ user: z9.object({ login: z9.string().min(1) }).strict()
1409
+ }).strict(),
1410
+ repository: z9.object({
1411
+ full_name: z9.string().min(1),
1412
+ id: z9.number().int().positive()
1413
+ }).strict()
1414
+ }).strict();
1415
+ var pullRequestOutcomeSchema = z9.object({
1416
+ action: z9.enum(["opened", "closed", "reopened"]),
1417
+ pull_request: z9.object({
1418
+ body: z9.string().nullable().optional(),
1419
+ closed_at: z9.string().nullable().optional(),
1420
+ created_at: z9.string(),
1421
+ id: z9.number().int().positive(),
1422
+ merged: z9.boolean(),
1423
+ merged_at: z9.string().nullable().optional(),
1424
+ number: z9.number().int().positive(),
1425
+ updated_at: z9.string(),
1426
+ user: z9.object({ login: z9.string().min(1) }).passthrough()
1427
+ }).passthrough(),
1428
+ repository: z9.object({
1429
+ full_name: z9.string().min(1),
1430
+ id: z9.number().int().positive()
1431
+ }).passthrough()
1432
+ }).passthrough().transform(
1433
+ (provider) => canonicalPullRequestOutcomeSchema.parse({
1434
+ action: provider.action,
1435
+ pull_request: {
1436
+ body: provider.pull_request.body,
1437
+ closed_at: provider.pull_request.closed_at,
1438
+ created_at: provider.pull_request.created_at,
1439
+ id: provider.pull_request.id,
1440
+ merged: provider.pull_request.merged,
1441
+ merged_at: provider.pull_request.merged_at,
1442
+ number: provider.pull_request.number,
1443
+ updated_at: provider.pull_request.updated_at,
1444
+ user: { login: provider.pull_request.user.login }
1445
+ },
1446
+ repository: {
1447
+ full_name: provider.repository.full_name,
1448
+ id: provider.repository.id
1449
+ }
1450
+ })
1451
+ );
1452
+ var pullRequestLifecycleActionSchema = z9.object({ action: z9.string() }).passthrough();
1453
+ var canonicalPullRequestConversationSchema = z9.object({
1454
+ pull_request: z9.object({
1455
+ body: z9.string().nullable().optional(),
1456
+ id: z9.number().int().positive(),
1457
+ user: z9.object({ login: z9.string().min(1) }).strict()
1458
+ }).strict(),
1459
+ sender: z9.object({ login: z9.string().min(1) }).strict()
1460
+ }).strict();
1461
+ var pullRequestConversationSchema = z9.object({
1462
+ pull_request: z9.object({
1463
+ body: z9.string().nullable().optional(),
1464
+ id: z9.number().int().positive(),
1465
+ user: z9.object({ login: z9.string().min(1) }).passthrough()
1466
+ }).passthrough(),
1467
+ sender: z9.object({ login: z9.string().min(1) }).passthrough()
1468
+ }).passthrough().transform(
1469
+ (provider) => canonicalPullRequestConversationSchema.parse({
1470
+ pull_request: {
1471
+ body: provider.pull_request.body,
1472
+ id: provider.pull_request.id,
1473
+ user: { login: provider.pull_request.user.login }
1474
+ },
1475
+ sender: { login: provider.sender.login }
1476
+ })
1477
+ );
1478
+ function timestamp3(value) {
1479
+ if (!value) return void 0;
1480
+ const parsed = new Date(value);
1481
+ return Number.isFinite(parsed.getTime()) ? parsed : void 0;
1482
+ }
1188
1483
  function normalizeGitHubPullRequestOutcome(args) {
1189
1484
  const lifecycle = pullRequestLifecycleActionSchema.safeParse(args.body);
1190
1485
  if (!lifecycle.success || !["opened", "closed", "reopened"].includes(lifecycle.data.action)) {
@@ -1192,8 +1487,8 @@ function normalizeGitHubPullRequestOutcome(args) {
1192
1487
  }
1193
1488
  const parsed = pullRequestOutcomeSchema.parse(args.body);
1194
1489
  const pullRequest = parsed.pull_request;
1195
- const openedAt = timestamp2(pullRequest.created_at);
1196
- const updatedAt = timestamp2(pullRequest.updated_at);
1490
+ const openedAt = timestamp3(pullRequest.created_at);
1491
+ const updatedAt = timestamp3(pullRequest.updated_at);
1197
1492
  if (!openedAt || !updatedAt) {
1198
1493
  throw new Error("GitHub pull request lifecycle timestamps are invalid");
1199
1494
  }
@@ -1209,8 +1504,8 @@ function normalizeGitHubPullRequestOutcome(args) {
1209
1504
  );
1210
1505
  const candidateOwned = hasOwnershipMarker && (parsed.action === "opened" || parsed.action === "closed");
1211
1506
  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);
1507
+ const closedAt = timestamp3(pullRequest.closed_at);
1508
+ const mergedAt = timestamp3(pullRequest.merged_at);
1214
1509
  if (parsed.action === "closed" && (pullRequest.merged ? !mergedAt : !closedAt)) {
1215
1510
  throw new Error("GitHub pull request terminal timestamp is invalid");
1216
1511
  }
@@ -1227,6 +1522,19 @@ function normalizeGitHubPullRequestOutcome(args) {
1227
1522
  updatedAt
1228
1523
  };
1229
1524
  }
1525
+ function normalizeGitHubPullRequestConversations(args) {
1526
+ const parsed = pullRequestConversationSchema.safeParse(args.body);
1527
+ const botLogin = botLoginFromEmail(args.botEmail)?.toLowerCase();
1528
+ if (!parsed.success || !botLogin) return void 0;
1529
+ if (parsed.data.pull_request.user.login.trim().toLowerCase() !== botLogin || parsed.data.sender.login.trim().toLowerCase() !== botLogin) {
1530
+ return void 0;
1531
+ }
1532
+ const conversationIds = githubConversationIds(parsed.data.pull_request.body);
1533
+ return conversationIds.length > 0 ? {
1534
+ conversationIds,
1535
+ pullRequestId: String(parsed.data.pull_request.id)
1536
+ } : void 0;
1537
+ }
1230
1538
 
1231
1539
  // src/webhooks/handler.ts
1232
1540
  function verifyGitHubSignature(body, signature, secret) {
@@ -1260,13 +1568,45 @@ function createGitHubWebhookRoute(args) {
1260
1568
  return new Response("Malformed GitHub webhook", { status: 400 });
1261
1569
  }
1262
1570
  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);
1571
+ const botEmail = args.botEmail();
1572
+ const pullRequestOutcome = eventName === "pull_request" ? normalizeGitHubPullRequestOutcome({ body, botEmail }) : void 0;
1573
+ const issueOutcome = eventName === "issues" ? normalizeGitHubIssueOutcome({ body, botEmail }) : void 0;
1574
+ const pullRequestConversations = eventName === "pull_request" ? normalizeGitHubPullRequestConversations({ body, botEmail }) : void 0;
1575
+ if (pullRequestOutcome) {
1576
+ const recordedOutcome = await recordGitHubPullRequestOutcome(
1577
+ args.db,
1578
+ pullRequestOutcome
1579
+ );
1580
+ if (recordedOutcome.applied && !recordedOutcome.commitComposition && pullRequestOutcome.state === "merged" && args.classifyPullRequestCommits) {
1581
+ let commitComposition;
1582
+ try {
1583
+ commitComposition = await args.classifyPullRequestCommits({
1584
+ number: pullRequestOutcome.number,
1585
+ repositoryFullName: pullRequestOutcome.repositoryFullName
1586
+ });
1587
+ } catch (error) {
1588
+ args.log?.error("GitHub PR commit classification failed", {
1589
+ deliveryId,
1590
+ errorType: error instanceof Error ? error.name : "UnknownError",
1591
+ number: pullRequestOutcome.number,
1592
+ repository: pullRequestOutcome.repositoryFullName
1593
+ });
1594
+ }
1595
+ if (commitComposition) {
1596
+ await recordGitHubPullRequestOutcome(args.db, {
1597
+ ...pullRequestOutcome,
1598
+ commitComposition
1599
+ });
1600
+ }
1601
+ }
1269
1602
  }
1603
+ if (issueOutcome) {
1604
+ await recordGitHubIssueOutcome(args.db, issueOutcome);
1605
+ }
1606
+ const recordedPullRequestConversations = pullRequestConversations ? await recordGitHubPullRequestConversations(
1607
+ args.db,
1608
+ pullRequestConversations
1609
+ ) : false;
1270
1610
  const resourceEvents = normalizeGitHubResourceEvents({
1271
1611
  body,
1272
1612
  deliveryId,
@@ -1275,7 +1615,7 @@ function createGitHubWebhookRoute(args) {
1275
1615
  for (const event of resourceEvents) {
1276
1616
  await args.resourceEvents.publish(event);
1277
1617
  }
1278
- if (!outcome && resourceEvents.length === 0) {
1618
+ if (!pullRequestOutcome && !issueOutcome && !recordedPullRequestConversations && resourceEvents.length === 0) {
1279
1619
  return new Response("Ignored", { status: 202 });
1280
1620
  }
1281
1621
  return new Response("Accepted", { status: 202 });
@@ -1283,32 +1623,40 @@ function createGitHubWebhookRoute(args) {
1283
1623
  };
1284
1624
  }
1285
1625
 
1286
- // src/pull-request-outcomes/report.ts
1287
- import { sql as sql2 } from "drizzle-orm";
1288
- import { z as z8 } from "zod";
1626
+ // src/outcomes/report.ts
1627
+ import { sql as sql3 } from "drizzle-orm";
1628
+ import { z as z10 } from "zod";
1289
1629
  var DAY_MS = 24 * 60 * 60 * 1e3;
1290
1630
  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()
1631
+ var pullRequestStatsSchema = z10.object({
1632
+ closed: z10.number().int().nonnegative(),
1633
+ compositionUnknown: z10.number().int().nonnegative(),
1634
+ created: z10.number().int().nonnegative(),
1635
+ days: z10.number().int().positive(),
1636
+ juniorOnly: z10.number().int().nonnegative(),
1637
+ medianMergeTimeMs: z10.number().nonnegative().nullable(),
1638
+ merged: z10.number().int().nonnegative(),
1639
+ mixed: z10.number().int().nonnegative(),
1640
+ open: z10.number().int().nonnegative()
1298
1641
  }).strict().transform((row) => {
1299
1642
  const terminal = row.merged + row.closed;
1643
+ const classified = row.juniorOnly + row.mixed;
1300
1644
  return {
1301
1645
  ...row,
1302
1646
  medianMergeTimeMs: row.medianMergeTimeMs ?? void 0,
1303
- mergeRate: terminal > 0 ? row.merged / terminal : void 0
1647
+ mergeRate: terminal > 0 ? row.merged / terminal : void 0,
1648
+ juniorOnlyRate: classified > 0 ? row.juniorOnly / classified : void 0
1304
1649
  };
1305
1650
  });
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)
1651
+ var pullRequestRepositoryStatsSchema = z10.object({
1652
+ closed: z10.number().int().nonnegative(),
1653
+ compositionUnknown: z10.number().int().nonnegative(),
1654
+ created: z10.number().int().nonnegative(),
1655
+ juniorOnly: z10.number().int().nonnegative(),
1656
+ merged: z10.number().int().nonnegative(),
1657
+ mixed: z10.number().int().nonnegative(),
1658
+ open: z10.number().int().nonnegative(),
1659
+ repository: z10.string().min(1)
1312
1660
  }).strict().transform((row) => {
1313
1661
  const terminal = row.merged + row.closed;
1314
1662
  return {
@@ -1316,19 +1664,35 @@ var repositoryStatsSchema = z8.object({
1316
1664
  mergeRate: terminal > 0 ? row.merged / terminal : void 0
1317
1665
  };
1318
1666
  });
1667
+ var issueStatsSchema = z10.object({
1668
+ closed: z10.number().int().nonnegative(),
1669
+ created: z10.number().int().nonnegative(),
1670
+ days: z10.number().int().positive(),
1671
+ medianCloseTimeMs: z10.number().nonnegative().nullable(),
1672
+ open: z10.number().int().nonnegative()
1673
+ }).strict().transform((row) => ({
1674
+ ...row,
1675
+ medianCloseTimeMs: row.medianCloseTimeMs ?? void 0
1676
+ }));
1677
+ var issueRepositoryStatsSchema = z10.object({
1678
+ closed: z10.number().int().nonnegative(),
1679
+ created: z10.number().int().nonnegative(),
1680
+ open: z10.number().int().nonnegative(),
1681
+ repository: z10.string().min(1)
1682
+ }).strict();
1319
1683
  function queryRows(result) {
1320
1684
  if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
1321
1685
  throw new TypeError("GitHub outcome query did not return rows");
1322
1686
  }
1323
1687
  return result.rows;
1324
1688
  }
1325
- async function aggregateOutcomeWindows(args) {
1689
+ async function aggregatePullRequestWindows(args) {
1326
1690
  const starts = WINDOWS.map(
1327
1691
  (days) => [days, new Date(args.nowMs - days * DAY_MS)]
1328
1692
  );
1329
1693
  const oldestStart = starts.at(-1)[1];
1330
1694
  const table = juniorGitHubPullRequests;
1331
- const result = await args.db.execute(sql2`
1695
+ const result = await args.db.execute(sql3`
1332
1696
  WITH windows(days, start_at) AS (
1333
1697
  VALUES
1334
1698
  (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
@@ -1338,6 +1702,7 @@ async function aggregateOutcomeWindows(args) {
1338
1702
  SELECT
1339
1703
  ${table.pullRequestId},
1340
1704
  ${table.state},
1705
+ ${table.commitComposition},
1341
1706
  ${table.openedAt},
1342
1707
  ${table.mergedAt},
1343
1708
  ${table.closedAt}
@@ -1364,6 +1729,24 @@ async function aggregateOutcomeWindows(args) {
1364
1729
  )::integer AS "closed",
1365
1730
  count(recent_pull_requests.pull_request_id)
1366
1731
  FILTER (WHERE recent_pull_requests.state = 'open')::integer AS "open",
1732
+ count(recent_pull_requests.pull_request_id)
1733
+ FILTER (
1734
+ WHERE recent_pull_requests.state = 'merged'
1735
+ AND recent_pull_requests.merged_at >= windows.start_at
1736
+ AND recent_pull_requests.commit_composition = 'junior_only'
1737
+ )::integer AS "juniorOnly",
1738
+ count(recent_pull_requests.pull_request_id)
1739
+ FILTER (
1740
+ WHERE recent_pull_requests.state = 'merged'
1741
+ AND recent_pull_requests.merged_at >= windows.start_at
1742
+ AND recent_pull_requests.commit_composition = 'mixed'
1743
+ )::integer AS "mixed",
1744
+ count(recent_pull_requests.pull_request_id)
1745
+ FILTER (
1746
+ WHERE recent_pull_requests.state = 'merged'
1747
+ AND recent_pull_requests.merged_at >= windows.start_at
1748
+ AND recent_pull_requests.commit_composition IS NULL
1749
+ )::integer AS "compositionUnknown",
1367
1750
  (
1368
1751
  percentile_cont(0.5) WITHIN GROUP (
1369
1752
  ORDER BY extract(
@@ -1381,12 +1764,12 @@ async function aggregateOutcomeWindows(args) {
1381
1764
  GROUP BY windows.days
1382
1765
  ORDER BY windows.days
1383
1766
  `);
1384
- return z8.array(outcomeStatsSchema).parse(queryRows(result));
1767
+ return z10.array(pullRequestStatsSchema).parse(queryRows(result));
1385
1768
  }
1386
- async function aggregateRepositories(args) {
1769
+ async function aggregatePullRequestRepositories(args) {
1387
1770
  const start = new Date(args.nowMs - 30 * DAY_MS);
1388
1771
  const table = juniorGitHubPullRequests;
1389
- const result = await args.db.execute(sql2`
1772
+ const result = await args.db.execute(sql3`
1390
1773
  SELECT
1391
1774
  ${table.repositoryFullName} AS "repository",
1392
1775
  count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
@@ -1398,7 +1781,22 @@ async function aggregateRepositories(args) {
1398
1781
  WHERE ${table.state} = 'closed_unmerged'
1399
1782
  AND ${table.closedAt} >= ${start}
1400
1783
  )::integer AS "closed",
1401
- count(*) FILTER (WHERE ${table.state} = 'open')::integer AS "open"
1784
+ count(*) FILTER (WHERE ${table.state} = 'open')::integer AS "open",
1785
+ count(*) FILTER (
1786
+ WHERE ${table.state} = 'merged'
1787
+ AND ${table.mergedAt} >= ${start}
1788
+ AND ${table.commitComposition} = 'junior_only'
1789
+ )::integer AS "juniorOnly",
1790
+ count(*) FILTER (
1791
+ WHERE ${table.state} = 'merged'
1792
+ AND ${table.mergedAt} >= ${start}
1793
+ AND ${table.commitComposition} = 'mixed'
1794
+ )::integer AS "mixed",
1795
+ count(*) FILTER (
1796
+ WHERE ${table.state} = 'merged'
1797
+ AND ${table.mergedAt} >= ${start}
1798
+ AND ${table.commitComposition} IS NULL
1799
+ )::integer AS "compositionUnknown"
1402
1800
  FROM ${table}
1403
1801
  WHERE ${table.state} = 'open'
1404
1802
  OR ${table.openedAt} >= ${start}
@@ -1408,7 +1806,81 @@ async function aggregateRepositories(args) {
1408
1806
  ORDER BY "merged" DESC, "created" DESC, "repository" ASC
1409
1807
  LIMIT 25
1410
1808
  `);
1411
- return z8.array(repositoryStatsSchema).parse(queryRows(result));
1809
+ return z10.array(pullRequestRepositoryStatsSchema).parse(queryRows(result));
1810
+ }
1811
+ async function aggregateIssueWindows(args) {
1812
+ const starts = WINDOWS.map(
1813
+ (days) => [days, new Date(args.nowMs - days * DAY_MS)]
1814
+ );
1815
+ const oldestStart = starts.at(-1)[1];
1816
+ const table = juniorGitHubIssues;
1817
+ const result = await args.db.execute(sql3`
1818
+ WITH windows(days, start_at) AS (
1819
+ VALUES
1820
+ (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
1821
+ (${starts[1][0]}::integer, ${starts[1][1]}::timestamptz),
1822
+ (${starts[2][0]}::integer, ${starts[2][1]}::timestamptz)
1823
+ ), recent_issues AS MATERIALIZED (
1824
+ SELECT
1825
+ ${table.issueId},
1826
+ ${table.state},
1827
+ ${table.openedAt},
1828
+ ${table.closedAt}
1829
+ FROM ${table}
1830
+ WHERE ${table.state} = 'open'
1831
+ OR ${table.openedAt} >= ${oldestStart}
1832
+ OR ${table.closedAt} >= ${oldestStart}
1833
+ )
1834
+ SELECT
1835
+ windows.days AS "days",
1836
+ count(recent_issues.issue_id)
1837
+ FILTER (WHERE recent_issues.opened_at >= windows.start_at)::integer
1838
+ AS "created",
1839
+ count(recent_issues.issue_id)
1840
+ FILTER (
1841
+ WHERE recent_issues.state = 'closed'
1842
+ AND recent_issues.closed_at >= windows.start_at
1843
+ )::integer AS "closed",
1844
+ count(recent_issues.issue_id)
1845
+ FILTER (WHERE recent_issues.state = 'open')::integer AS "open",
1846
+ (
1847
+ percentile_cont(0.5) WITHIN GROUP (
1848
+ ORDER BY extract(
1849
+ epoch FROM (recent_issues.closed_at - recent_issues.opened_at)
1850
+ ) * 1000
1851
+ ) FILTER (
1852
+ WHERE recent_issues.state = 'closed'
1853
+ AND recent_issues.closed_at >= windows.start_at
1854
+ )
1855
+ )::double precision AS "medianCloseTimeMs"
1856
+ FROM windows
1857
+ LEFT JOIN recent_issues ON true
1858
+ GROUP BY windows.days
1859
+ ORDER BY windows.days
1860
+ `);
1861
+ return z10.array(issueStatsSchema).parse(queryRows(result));
1862
+ }
1863
+ async function aggregateIssueRepositories(args) {
1864
+ const start = new Date(args.nowMs - 30 * DAY_MS);
1865
+ const table = juniorGitHubIssues;
1866
+ const result = await args.db.execute(sql3`
1867
+ SELECT
1868
+ ${table.repositoryFullName} AS "repository",
1869
+ count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
1870
+ AS "created",
1871
+ count(*) FILTER (
1872
+ WHERE ${table.state} = 'closed' AND ${table.closedAt} >= ${start}
1873
+ )::integer AS "closed",
1874
+ count(*) FILTER (WHERE ${table.state} = 'open')::integer AS "open"
1875
+ FROM ${table}
1876
+ WHERE ${table.state} = 'open'
1877
+ OR ${table.openedAt} >= ${start}
1878
+ OR ${table.closedAt} >= ${start}
1879
+ GROUP BY ${table.repositoryFullName}
1880
+ ORDER BY "created" DESC, "closed" DESC, "repository" ASC
1881
+ LIMIT 25
1882
+ `);
1883
+ return z10.array(issueRepositoryStatsSchema).parse(queryRows(result));
1412
1884
  }
1413
1885
  function formatPercent(value) {
1414
1886
  return value === void 0 ? "\u2014" : `${Math.round(value * 100)}%`;
@@ -1420,38 +1892,53 @@ function formatDuration(value) {
1420
1892
  if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
1421
1893
  return `${Math.round(hours / 24 * 10) / 10}d`;
1422
1894
  }
1895
+ function formatCommitComposition(stats) {
1896
+ return `${stats.juniorOnly} Junior-only \xB7 ${stats.mixed} mixed \xB7 ${stats.compositionUnknown} unknown`;
1897
+ }
1423
1898
  async function buildGitHubOutcomeReport(args) {
1424
- const [windows, repositories] = await Promise.all([
1425
- aggregateOutcomeWindows(args),
1426
- aggregateRepositories(args)
1899
+ const [windows, repositories, issueWindows, issueRepositories] = await Promise.all([
1900
+ aggregatePullRequestWindows(args),
1901
+ aggregatePullRequestRepositories(args),
1902
+ aggregateIssueWindows(args),
1903
+ aggregateIssueRepositories(args)
1427
1904
  ]);
1428
1905
  const thirtyDays = windows.find((window) => window.days === 30);
1906
+ const issueThirtyDays = issueWindows.find((window) => window.days === 30);
1429
1907
  return {
1430
1908
  generatedAt: new Date(args.nowMs).toISOString(),
1431
1909
  title: "GitHub work delivered",
1432
1910
  metrics: [
1433
- { label: "created \xB7 30d", value: String(thirtyDays.created) },
1911
+ { label: "PRs created \xB7 30d", value: String(thirtyDays.created) },
1434
1912
  {
1435
- label: "merged \xB7 30d",
1913
+ label: "PRs merged \xB7 30d",
1436
1914
  tone: thirtyDays.merged > 0 ? "good" : "neutral",
1437
1915
  value: String(thirtyDays.merged)
1438
1916
  },
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
1917
  {
1443
- label: "median merge time \xB7 30d",
1918
+ label: "Junior-only merge rate \xB7 30d",
1919
+ value: formatPercent(thirtyDays.juniorOnlyRate)
1920
+ },
1921
+ { label: "PRs open now", value: String(thirtyDays.open) },
1922
+ {
1923
+ label: "PR merge rate \xB7 30d",
1924
+ value: formatPercent(thirtyDays.mergeRate)
1925
+ },
1926
+ {
1927
+ label: "median PR merge time \xB7 30d",
1444
1928
  value: formatDuration(thirtyDays.medianMergeTimeMs)
1445
- }
1929
+ },
1930
+ { label: "issues created \xB7 30d", value: String(issueThirtyDays.created) },
1931
+ { label: "issues open now", value: String(issueThirtyDays.open) }
1446
1932
  ],
1447
1933
  recordSets: [
1448
1934
  {
1449
- title: "Outcome windows",
1935
+ title: "Pull request outcome windows",
1450
1936
  fields: [
1451
1937
  { key: "window", label: "Window" },
1452
1938
  { key: "created", label: "Created" },
1453
1939
  { key: "merged", label: "Merged" },
1454
1940
  { key: "closed", label: "Closed unmerged" },
1941
+ { key: "commitComposition", label: "Merged commit composition" },
1455
1942
  { key: "mergeRate", label: "Merge rate" },
1456
1943
  { key: "mergeTime", label: "Median merge time" }
1457
1944
  ],
@@ -1462,13 +1949,14 @@ async function buildGitHubOutcomeReport(args) {
1462
1949
  created: String(stats.created),
1463
1950
  merged: String(stats.merged),
1464
1951
  closed: String(stats.closed),
1952
+ commitComposition: formatCommitComposition(stats),
1465
1953
  mergeRate: formatPercent(stats.mergeRate),
1466
1954
  mergeTime: formatDuration(stats.medianMergeTimeMs)
1467
1955
  }
1468
1956
  }))
1469
1957
  },
1470
1958
  {
1471
- title: "Repositories \xB7 30d",
1959
+ title: "Pull request repositories \xB7 30d",
1472
1960
  emptyText: "No Junior-owned pull request activity yet.",
1473
1961
  fields: [
1474
1962
  { key: "repository", label: "Repository" },
@@ -1476,6 +1964,7 @@ async function buildGitHubOutcomeReport(args) {
1476
1964
  { key: "merged", label: "Merged" },
1477
1965
  { key: "closed", label: "Closed unmerged" },
1478
1966
  { key: "open", label: "Open now" },
1967
+ { key: "commitComposition", label: "Merged commit composition" },
1479
1968
  { key: "mergeRate", label: "Merge rate" }
1480
1969
  ],
1481
1970
  records: repositories.map(({ repository, ...stats }) => ({
@@ -1486,14 +1975,107 @@ async function buildGitHubOutcomeReport(args) {
1486
1975
  merged: String(stats.merged),
1487
1976
  closed: String(stats.closed),
1488
1977
  open: String(stats.open),
1978
+ commitComposition: formatCommitComposition(stats),
1489
1979
  mergeRate: formatPercent(stats.mergeRate)
1490
1980
  }
1491
1981
  }))
1982
+ },
1983
+ {
1984
+ title: "Issue outcome windows",
1985
+ fields: [
1986
+ { key: "window", label: "Window" },
1987
+ { key: "created", label: "Created" },
1988
+ { key: "closed", label: "Closed" },
1989
+ { key: "open", label: "Open now" },
1990
+ { key: "closeTime", label: "Median close time" }
1991
+ ],
1992
+ records: issueWindows.map((stats) => ({
1993
+ id: `${stats.days}d`,
1994
+ values: {
1995
+ window: `${stats.days} days`,
1996
+ created: String(stats.created),
1997
+ closed: String(stats.closed),
1998
+ open: String(stats.open),
1999
+ closeTime: formatDuration(stats.medianCloseTimeMs)
2000
+ }
2001
+ }))
2002
+ },
2003
+ {
2004
+ title: "Issue repositories \xB7 30d",
2005
+ emptyText: "No Junior-owned issue activity yet.",
2006
+ fields: [
2007
+ { key: "repository", label: "Repository" },
2008
+ { key: "created", label: "Created" },
2009
+ { key: "closed", label: "Closed" },
2010
+ { key: "open", label: "Open now" }
2011
+ ],
2012
+ records: issueRepositories.map(({ repository, ...stats }) => ({
2013
+ id: repository,
2014
+ values: {
2015
+ repository,
2016
+ created: String(stats.created),
2017
+ closed: String(stats.closed),
2018
+ open: String(stats.open)
2019
+ }
2020
+ }))
1492
2021
  }
1493
2022
  ]
1494
2023
  };
1495
2024
  }
1496
2025
 
2026
+ // src/pull-request-outcomes/commit-composition.ts
2027
+ import { z as z11 } from "zod";
2028
+ var canonicalCommitSchema = z11.object({
2029
+ authorEmail: z11.string().nullable(),
2030
+ authorLogin: z11.string().nullable()
2031
+ }).strict();
2032
+ var providerCommitSchema = z11.object({
2033
+ author: z11.object({ login: z11.string() }).passthrough().nullable(),
2034
+ commit: z11.object({
2035
+ author: z11.object({ email: z11.string() }).passthrough().nullable()
2036
+ }).passthrough()
2037
+ }).passthrough();
2038
+ var commitPageSchema = z11.array(providerCommitSchema).transform(
2039
+ (commits) => commits.map(
2040
+ (commit) => canonicalCommitSchema.parse({
2041
+ authorEmail: commit.commit.author?.email ?? null,
2042
+ authorLogin: commit.author?.login ?? null
2043
+ })
2044
+ )
2045
+ );
2046
+ var PAGE_SIZE = 100;
2047
+ var MAX_PAGES = 3;
2048
+ var MAX_LISTED_COMMITS = 250;
2049
+ async function classifyGitHubPullRequestCommitComposition(args) {
2050
+ const botEmail = args.botEmail.trim().toLowerCase();
2051
+ const botLogin = botLoginFromEmail(botEmail)?.toLowerCase();
2052
+ if (!botEmail || !botLogin) {
2053
+ throw new Error(
2054
+ "The configured GitHub App bot email cannot classify pull request commits"
2055
+ );
2056
+ }
2057
+ let foundCommit = false;
2058
+ let inspectedCommits = 0;
2059
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
2060
+ const commits = commitPageSchema.parse(
2061
+ await args.loadPage(page, PAGE_SIZE)
2062
+ );
2063
+ inspectedCommits += commits.length;
2064
+ for (const commit of commits) {
2065
+ foundCommit = true;
2066
+ const authorLogin = commit.authorLogin?.trim().toLowerCase();
2067
+ const authorEmail = commit.authorEmail?.trim().toLowerCase();
2068
+ if (authorLogin !== botLogin && authorEmail !== botEmail) {
2069
+ return "mixed";
2070
+ }
2071
+ }
2072
+ if (inspectedCommits >= MAX_LISTED_COMMITS) return void 0;
2073
+ if (commits.length < PAGE_SIZE) break;
2074
+ if (page === MAX_PAGES) return void 0;
2075
+ }
2076
+ return foundCommit ? "junior_only" : void 0;
2077
+ }
2078
+
1497
2079
  // src/index.ts
1498
2080
  var GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
1499
2081
  var GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
@@ -2250,7 +2832,7 @@ async function issueUserCredential(ctx, options) {
2250
2832
  }
2251
2833
  return credentialNeeded("Your GitHub authorization has expired.", scope);
2252
2834
  }
2253
- async function issueInstallationCredential(options) {
2835
+ async function issueInstallationToken(options) {
2254
2836
  const appId = requireEnv(options.appIdEnv);
2255
2837
  const installationIdRaw = requireEnv(options.installationIdEnv);
2256
2838
  const installationId = Number(installationIdRaw);
@@ -2258,8 +2840,11 @@ async function issueInstallationCredential(options) {
2258
2840
  throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
2259
2841
  }
2260
2842
  const appJwt = createAppJwt(appId, options.privateKeyEnv);
2261
- const body = "repositories" in options ? { repositories: options.repositories } : "repositoryIds" in options ? { repository_ids: options.repositoryIds } : {
2262
- permissions: "permissions" in options ? options.permissions : await options.loadPermissions({ appJwt, installationId })
2843
+ const permissions = "permissions" in options ? options.permissions : typeof options.loadPermissions === "function" ? await options.loadPermissions({ appJwt, installationId }) : void 0;
2844
+ const body = {
2845
+ ...permissions ? { permissions } : {},
2846
+ ..."repositories" in options ? { repositories: options.repositories } : {},
2847
+ ..."repositoryIds" in options ? { repository_ids: options.repositoryIds } : {}
2263
2848
  };
2264
2849
  const accessTokenResponse = await githubRequest(
2265
2850
  "https://api.github.com",
@@ -2271,14 +2856,17 @@ async function issueInstallationCredential(options) {
2271
2856
  }
2272
2857
  );
2273
2858
  const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
2274
- const expiresAtMs = Math.min(
2275
- parsedToken.expiresAtMs,
2276
- Date.now() + MAX_LEASE_MS
2277
- );
2859
+ return {
2860
+ expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS),
2861
+ token: parsedToken.token
2862
+ };
2863
+ }
2864
+ async function issueInstallationCredential(options) {
2865
+ const token = await issueInstallationToken(options);
2278
2866
  return createCredentialLease({
2279
2867
  ...options.domains ? { domains: options.domains } : {},
2280
- token: parsedToken.token,
2281
- expiresAtMs
2868
+ token: token.token,
2869
+ expiresAtMs: token.expiresAtMs
2282
2870
  });
2283
2871
  }
2284
2872
  function createPermissionCache() {
@@ -2725,7 +3313,34 @@ function githubPlugin(options = {}) {
2725
3313
  return [
2726
3314
  createGitHubWebhookRoute({
2727
3315
  botEmail: () => readEnv(botEmailEnv),
3316
+ classifyPullRequestCommits: async ({
3317
+ number,
3318
+ repositoryFullName
3319
+ }) => {
3320
+ const [owner, name, ...extra] = repositoryFullName.split("/");
3321
+ if (!owner || !name || extra.length > 0) {
3322
+ throw new Error(
3323
+ "GitHub pull request commit classification received an invalid repository name"
3324
+ );
3325
+ }
3326
+ const token = await issueInstallationToken({
3327
+ appIdEnv,
3328
+ privateKeyEnv,
3329
+ installationIdEnv,
3330
+ permissions: { pull_requests: "read" },
3331
+ repositories: [name]
3332
+ });
3333
+ return await classifyGitHubPullRequestCommitComposition({
3334
+ botEmail: requireEnv(botEmailEnv),
3335
+ loadPage: async (page, perPage) => await githubRequest(
3336
+ "https://api.github.com",
3337
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/commits?per_page=${perPage}&page=${page}`,
3338
+ { token: token.token }
3339
+ )
3340
+ });
3341
+ },
2728
3342
  db: ctx.db,
3343
+ log: ctx.log,
2729
3344
  resourceEvents: ctx.resourceEvents,
2730
3345
  webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET")
2731
3346
  })