@sentry/junior-github 0.107.1 → 0.109.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,36 @@ 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 githubIssueStateReasonSchema = z5.enum([
1117
+ "completed",
1118
+ "duplicate",
1119
+ "not_planned",
1120
+ "reopened"
1121
+ ]);
1122
+ var juniorGitHubIssues = pgTable(
1123
+ "junior_github_issues",
1124
+ {
1125
+ issueId: text("issue_id").primaryKey(),
1126
+ repositoryId: text("repository_id").notNull(),
1127
+ repositoryFullName: text("repository_full_name").notNull(),
1128
+ number: integer("number").notNull(),
1129
+ state: text("state").$type().notNull(),
1130
+ stateReason: text("state_reason").$type(),
1131
+ openedAt: timestamp("opened_at", { withTimezone: true }).notNull(),
1132
+ closedAt: timestamp("closed_at", { withTimezone: true }),
1133
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull()
1134
+ },
1135
+ (table) => [
1136
+ index("junior_github_issues_opened_at_idx").on(table.openedAt),
1137
+ index("junior_github_issues_closed_at_idx").on(table.closedAt),
1138
+ index("junior_github_issues_open_idx").on(table.issueId).where(sql`${table.state} = 'open'`)
1139
+ ]
1140
+ );
1085
1141
  var juniorGitHubPullRequests = pgTable(
1086
1142
  "junior_github_pull_requests",
1087
1143
  {
@@ -1090,6 +1146,8 @@ var juniorGitHubPullRequests = pgTable(
1090
1146
  repositoryFullName: text("repository_full_name").notNull(),
1091
1147
  number: integer("number").notNull(),
1092
1148
  state: text("state").$type().notNull(),
1149
+ commitComposition: text("commit_composition").$type(),
1150
+ conversationIds: text("conversation_ids").array().notNull().default(sql`ARRAY[]::text[]`),
1093
1151
  openedAt: timestamp("opened_at", { withTimezone: true }).notNull(),
1094
1152
  mergedAt: timestamp("merged_at", { withTimezone: true }),
1095
1153
  closedAt: timestamp("closed_at", { withTimezone: true }),
@@ -1103,71 +1161,137 @@ var juniorGitHubPullRequests = pgTable(
1103
1161
  ]
1104
1162
  );
1105
1163
 
1106
- // src/pull-request-outcomes/store.ts
1107
- var githubPullRequestOutcomeInputSchema = z6.object({
1164
+ // src/issue-outcomes/store.ts
1165
+ var githubIssueOutcomeInputSchema = z6.object({
1108
1166
  candidateOwned: z6.boolean(),
1109
1167
  closedAt: z6.date().optional(),
1110
- mergedAt: z6.date().optional(),
1168
+ issueId: z6.string().min(1),
1111
1169
  number: z6.number().int().positive(),
1112
1170
  openedAt: z6.date(),
1113
- pullRequestId: z6.string().min(1),
1114
1171
  repositoryFullName: z6.string().min(1),
1115
1172
  repositoryId: z6.string().min(1),
1116
- state: githubPullRequestStateSchema,
1173
+ state: githubIssueStateSchema,
1174
+ stateReason: githubIssueStateReasonSchema.optional(),
1117
1175
  updatedAt: z6.date()
1118
1176
  }).strict();
1119
1177
  function projectionValues(input) {
1120
1178
  return {
1121
1179
  closedAt: input.closedAt ?? null,
1122
- mergedAt: input.mergedAt ?? null,
1123
1180
  number: input.number,
1124
1181
  openedAt: input.openedAt,
1125
1182
  repositoryFullName: input.repositoryFullName,
1126
1183
  repositoryId: input.repositoryId,
1127
1184
  state: input.state,
1185
+ stateReason: input.stateReason ?? null,
1128
1186
  updatedAt: input.updatedAt
1129
1187
  };
1130
1188
  }
1131
- async function recordGitHubPullRequestOutcome(db, input) {
1132
- const outcome = githubPullRequestOutcomeInputSchema.parse(input);
1189
+ async function recordGitHubIssueOutcome(db, input) {
1190
+ const outcome = githubIssueOutcomeInputSchema.parse(input);
1133
1191
  const values = projectionValues(outcome);
1134
1192
  if (!outcome.candidateOwned) {
1135
- await db.update(juniorGitHubPullRequests).set(values).where(
1193
+ await db.update(juniorGitHubIssues).set(values).where(
1136
1194
  and(
1137
- eq(juniorGitHubPullRequests.pullRequestId, outcome.pullRequestId),
1138
- lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1195
+ eq(juniorGitHubIssues.issueId, outcome.issueId),
1196
+ lte(juniorGitHubIssues.updatedAt, outcome.updatedAt)
1139
1197
  )
1140
1198
  );
1141
1199
  return;
1142
1200
  }
1143
- await db.insert(juniorGitHubPullRequests).values({ pullRequestId: outcome.pullRequestId, ...values }).onConflictDoUpdate({
1144
- target: juniorGitHubPullRequests.pullRequestId,
1201
+ await db.insert(juniorGitHubIssues).values({ issueId: outcome.issueId, ...values }).onConflictDoUpdate({
1202
+ target: juniorGitHubIssues.issueId,
1145
1203
  set: values,
1146
- where: lte(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1204
+ where: lte(juniorGitHubIssues.updatedAt, outcome.updatedAt)
1147
1205
  });
1148
1206
  }
1149
1207
 
1150
- // src/webhooks/pull-request-outcome.ts
1208
+ // src/pull-request-outcomes/store.ts
1209
+ import { and as and2, eq as eq2, lte as lte2, sql as sql2 } from "drizzle-orm";
1151
1210
  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() });
1211
+ var githubPullRequestOutcomeInputSchema = z7.object({
1212
+ candidateOwned: z7.boolean(),
1213
+ closedAt: z7.date().optional(),
1214
+ commitComposition: githubPullRequestCommitCompositionSchema.optional(),
1215
+ mergedAt: z7.date().optional(),
1216
+ number: z7.number().int().positive(),
1217
+ openedAt: z7.date(),
1218
+ pullRequestId: z7.string().min(1),
1219
+ repositoryFullName: z7.string().min(1),
1220
+ repositoryId: z7.string().min(1),
1221
+ state: githubPullRequestStateSchema,
1222
+ updatedAt: z7.date()
1223
+ }).strict();
1224
+ var githubPullRequestConversationsInputSchema = z7.object({
1225
+ conversationIds: z7.array(z7.string().min(1)).min(1),
1226
+ pullRequestId: z7.string().min(1)
1227
+ }).strict();
1228
+ function projectionValues2(input) {
1229
+ return {
1230
+ closedAt: input.closedAt ?? null,
1231
+ ...input.commitComposition ? { commitComposition: input.commitComposition } : {},
1232
+ mergedAt: input.mergedAt ?? null,
1233
+ number: input.number,
1234
+ openedAt: input.openedAt,
1235
+ repositoryFullName: input.repositoryFullName,
1236
+ repositoryId: input.repositoryId,
1237
+ state: input.state,
1238
+ updatedAt: input.updatedAt
1239
+ };
1240
+ }
1241
+ async function recordGitHubPullRequestOutcome(db, input) {
1242
+ const outcome = githubPullRequestOutcomeInputSchema.parse(input);
1243
+ const values = projectionValues2(outcome);
1244
+ if (!outcome.candidateOwned) {
1245
+ const updated = await db.update(juniorGitHubPullRequests).set(values).where(
1246
+ and2(
1247
+ eq2(juniorGitHubPullRequests.pullRequestId, outcome.pullRequestId),
1248
+ lte2(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1249
+ )
1250
+ ).returning({
1251
+ commitComposition: juniorGitHubPullRequests.commitComposition
1252
+ });
1253
+ return {
1254
+ applied: updated.length > 0,
1255
+ commitComposition: updated[0]?.commitComposition ?? void 0
1256
+ };
1257
+ }
1258
+ const inserted = await db.insert(juniorGitHubPullRequests).values({ pullRequestId: outcome.pullRequestId, ...values }).onConflictDoUpdate({
1259
+ target: juniorGitHubPullRequests.pullRequestId,
1260
+ set: values,
1261
+ where: lte2(juniorGitHubPullRequests.updatedAt, outcome.updatedAt)
1262
+ }).returning({
1263
+ commitComposition: juniorGitHubPullRequests.commitComposition
1264
+ });
1265
+ return {
1266
+ applied: inserted.length > 0,
1267
+ commitComposition: inserted[0]?.commitComposition ?? void 0
1268
+ };
1269
+ }
1270
+ async function recordGitHubPullRequestConversations(db, input) {
1271
+ const association = githubPullRequestConversationsInputSchema.parse(input);
1272
+ const conversationIds = sql2.join(
1273
+ association.conversationIds.map((id) => sql2`${id}`),
1274
+ sql2`, `
1275
+ );
1276
+ const updated = await db.update(juniorGitHubPullRequests).set({
1277
+ conversationIds: sql2`ARRAY(
1278
+ SELECT DISTINCT value
1279
+ FROM unnest(
1280
+ ${juniorGitHubPullRequests.conversationIds}
1281
+ || ARRAY[${conversationIds}]::text[]
1282
+ ) AS value
1283
+ ORDER BY value
1284
+ )`
1285
+ }).where(
1286
+ eq2(juniorGitHubPullRequests.pullRequestId, association.pullRequestId)
1287
+ ).returning({ pullRequestId: juniorGitHubPullRequests.pullRequestId });
1288
+ return updated.length > 0;
1289
+ }
1290
+
1291
+ // src/webhooks/issue-outcome.ts
1292
+ import { z as z8 } from "zod";
1293
+
1294
+ // src/webhooks/ownership.ts
1171
1295
  var GITHUB_NOREPLY_DOMAIN = "users.noreply.github.com";
1172
1296
  function botLoginFromEmail(value) {
1173
1297
  const email = value?.trim();
@@ -1180,11 +1304,195 @@ function botLoginFromEmail(value) {
1180
1304
  const login = localPart.slice(localPart.indexOf("+") + 1).trim();
1181
1305
  return login.toLowerCase().endsWith("[bot]") ? login : void 0;
1182
1306
  }
1307
+
1308
+ // src/webhooks/issue-outcome.ts
1309
+ var canonicalIssueOutcomeSchema = z8.object({
1310
+ action: z8.enum(["opened", "closed", "reopened"]),
1311
+ issue: z8.object({
1312
+ body: z8.string().nullable().optional(),
1313
+ closed_at: z8.string().nullable().optional(),
1314
+ created_at: z8.string(),
1315
+ id: z8.number().int().positive(),
1316
+ number: z8.number().int().positive(),
1317
+ state_reason: githubIssueStateReasonSchema.nullable().optional(),
1318
+ updated_at: z8.string(),
1319
+ user: z8.object({ login: z8.string().min(1) }).strict()
1320
+ }).strict(),
1321
+ repository: z8.object({
1322
+ full_name: z8.string().min(1),
1323
+ id: z8.number().int().positive()
1324
+ }).strict()
1325
+ }).strict();
1326
+ var issueOutcomeSchema = z8.object({
1327
+ action: z8.enum(["opened", "closed", "reopened"]),
1328
+ issue: z8.object({
1329
+ body: z8.string().nullable().optional(),
1330
+ closed_at: z8.string().nullable().optional(),
1331
+ created_at: z8.string(),
1332
+ id: z8.number().int().positive(),
1333
+ number: z8.number().int().positive(),
1334
+ state_reason: githubIssueStateReasonSchema.nullable().optional(),
1335
+ updated_at: z8.string(),
1336
+ user: z8.object({ login: z8.string().min(1) }).passthrough()
1337
+ }).passthrough(),
1338
+ repository: z8.object({
1339
+ full_name: z8.string().min(1),
1340
+ id: z8.number().int().positive()
1341
+ }).passthrough()
1342
+ }).passthrough().transform(
1343
+ (provider) => canonicalIssueOutcomeSchema.parse({
1344
+ action: provider.action,
1345
+ issue: {
1346
+ body: provider.issue.body,
1347
+ closed_at: provider.issue.closed_at,
1348
+ created_at: provider.issue.created_at,
1349
+ id: provider.issue.id,
1350
+ number: provider.issue.number,
1351
+ state_reason: provider.issue.state_reason,
1352
+ updated_at: provider.issue.updated_at,
1353
+ user: { login: provider.issue.user.login }
1354
+ },
1355
+ repository: {
1356
+ full_name: provider.repository.full_name,
1357
+ id: provider.repository.id
1358
+ }
1359
+ })
1360
+ );
1361
+ var issueLifecycleActionSchema = z8.object({ action: z8.string() }).passthrough();
1183
1362
  function timestamp2(value) {
1184
1363
  if (!value) return void 0;
1185
1364
  const parsed = new Date(value);
1186
1365
  return Number.isFinite(parsed.getTime()) ? parsed : void 0;
1187
1366
  }
1367
+ function normalizeGitHubIssueOutcome(args) {
1368
+ const lifecycle = issueLifecycleActionSchema.safeParse(args.body);
1369
+ if (!lifecycle.success || !["opened", "closed", "reopened"].includes(lifecycle.data.action)) {
1370
+ return void 0;
1371
+ }
1372
+ const parsed = issueOutcomeSchema.parse(args.body);
1373
+ const issue = parsed.issue;
1374
+ const openedAt = timestamp2(issue.created_at);
1375
+ const updatedAt = timestamp2(issue.updated_at);
1376
+ if (!openedAt || !updatedAt) {
1377
+ throw new Error("GitHub issue lifecycle timestamps are invalid");
1378
+ }
1379
+ const authorLogin = issue.user.login.trim().toLowerCase();
1380
+ const botLogin = botLoginFromEmail(args.botEmail)?.toLowerCase();
1381
+ if (parsed.action === "opened" && !botLogin) {
1382
+ throw new Error(
1383
+ "The configured GitHub App bot email must encode a [bot] login in GitHub's noreply format to classify issue ownership"
1384
+ );
1385
+ }
1386
+ const hasOwnershipMarker = Boolean(
1387
+ botLogin && authorLogin === botLogin && issue.body?.includes(GITHUB_SESSION_FOOTER_START)
1388
+ );
1389
+ const candidateOwned = hasOwnershipMarker && (parsed.action === "opened" || parsed.action === "closed");
1390
+ const closedAt = timestamp2(issue.closed_at);
1391
+ if (parsed.action === "closed" && !closedAt) {
1392
+ throw new Error("GitHub issue terminal timestamp is invalid");
1393
+ }
1394
+ return {
1395
+ candidateOwned,
1396
+ closedAt,
1397
+ issueId: String(issue.id),
1398
+ number: issue.number,
1399
+ openedAt,
1400
+ repositoryFullName: parsed.repository.full_name,
1401
+ repositoryId: String(parsed.repository.id),
1402
+ state: parsed.action === "closed" ? "closed" : "open",
1403
+ stateReason: parsed.action === "closed" ? issue.state_reason ?? void 0 : void 0,
1404
+ updatedAt
1405
+ };
1406
+ }
1407
+
1408
+ // src/webhooks/pull-request-outcome.ts
1409
+ import { z as z9 } from "zod";
1410
+ var canonicalPullRequestOutcomeSchema = z9.object({
1411
+ action: z9.enum(["opened", "closed", "reopened"]),
1412
+ pull_request: z9.object({
1413
+ body: z9.string().nullable().optional(),
1414
+ closed_at: z9.string().nullable().optional(),
1415
+ created_at: z9.string(),
1416
+ id: z9.number().int().positive(),
1417
+ merged: z9.boolean(),
1418
+ merged_at: z9.string().nullable().optional(),
1419
+ number: z9.number().int().positive(),
1420
+ updated_at: z9.string(),
1421
+ user: z9.object({ login: z9.string().min(1) }).strict()
1422
+ }).strict(),
1423
+ repository: z9.object({
1424
+ full_name: z9.string().min(1),
1425
+ id: z9.number().int().positive()
1426
+ }).strict()
1427
+ }).strict();
1428
+ var pullRequestOutcomeSchema = z9.object({
1429
+ action: z9.enum(["opened", "closed", "reopened"]),
1430
+ pull_request: z9.object({
1431
+ body: z9.string().nullable().optional(),
1432
+ closed_at: z9.string().nullable().optional(),
1433
+ created_at: z9.string(),
1434
+ id: z9.number().int().positive(),
1435
+ merged: z9.boolean(),
1436
+ merged_at: z9.string().nullable().optional(),
1437
+ number: z9.number().int().positive(),
1438
+ updated_at: z9.string(),
1439
+ user: z9.object({ login: z9.string().min(1) }).passthrough()
1440
+ }).passthrough(),
1441
+ repository: z9.object({
1442
+ full_name: z9.string().min(1),
1443
+ id: z9.number().int().positive()
1444
+ }).passthrough()
1445
+ }).passthrough().transform(
1446
+ (provider) => canonicalPullRequestOutcomeSchema.parse({
1447
+ action: provider.action,
1448
+ pull_request: {
1449
+ body: provider.pull_request.body,
1450
+ closed_at: provider.pull_request.closed_at,
1451
+ created_at: provider.pull_request.created_at,
1452
+ id: provider.pull_request.id,
1453
+ merged: provider.pull_request.merged,
1454
+ merged_at: provider.pull_request.merged_at,
1455
+ number: provider.pull_request.number,
1456
+ updated_at: provider.pull_request.updated_at,
1457
+ user: { login: provider.pull_request.user.login }
1458
+ },
1459
+ repository: {
1460
+ full_name: provider.repository.full_name,
1461
+ id: provider.repository.id
1462
+ }
1463
+ })
1464
+ );
1465
+ var pullRequestLifecycleActionSchema = z9.object({ action: z9.string() }).passthrough();
1466
+ var canonicalPullRequestConversationSchema = z9.object({
1467
+ pull_request: z9.object({
1468
+ body: z9.string().nullable().optional(),
1469
+ id: z9.number().int().positive(),
1470
+ user: z9.object({ login: z9.string().min(1) }).strict()
1471
+ }).strict(),
1472
+ sender: z9.object({ login: z9.string().min(1) }).strict()
1473
+ }).strict();
1474
+ var pullRequestConversationSchema = z9.object({
1475
+ pull_request: z9.object({
1476
+ body: z9.string().nullable().optional(),
1477
+ id: z9.number().int().positive(),
1478
+ user: z9.object({ login: z9.string().min(1) }).passthrough()
1479
+ }).passthrough(),
1480
+ sender: z9.object({ login: z9.string().min(1) }).passthrough()
1481
+ }).passthrough().transform(
1482
+ (provider) => canonicalPullRequestConversationSchema.parse({
1483
+ pull_request: {
1484
+ body: provider.pull_request.body,
1485
+ id: provider.pull_request.id,
1486
+ user: { login: provider.pull_request.user.login }
1487
+ },
1488
+ sender: { login: provider.sender.login }
1489
+ })
1490
+ );
1491
+ function timestamp3(value) {
1492
+ if (!value) return void 0;
1493
+ const parsed = new Date(value);
1494
+ return Number.isFinite(parsed.getTime()) ? parsed : void 0;
1495
+ }
1188
1496
  function normalizeGitHubPullRequestOutcome(args) {
1189
1497
  const lifecycle = pullRequestLifecycleActionSchema.safeParse(args.body);
1190
1498
  if (!lifecycle.success || !["opened", "closed", "reopened"].includes(lifecycle.data.action)) {
@@ -1192,8 +1500,8 @@ function normalizeGitHubPullRequestOutcome(args) {
1192
1500
  }
1193
1501
  const parsed = pullRequestOutcomeSchema.parse(args.body);
1194
1502
  const pullRequest = parsed.pull_request;
1195
- const openedAt = timestamp2(pullRequest.created_at);
1196
- const updatedAt = timestamp2(pullRequest.updated_at);
1503
+ const openedAt = timestamp3(pullRequest.created_at);
1504
+ const updatedAt = timestamp3(pullRequest.updated_at);
1197
1505
  if (!openedAt || !updatedAt) {
1198
1506
  throw new Error("GitHub pull request lifecycle timestamps are invalid");
1199
1507
  }
@@ -1209,8 +1517,8 @@ function normalizeGitHubPullRequestOutcome(args) {
1209
1517
  );
1210
1518
  const candidateOwned = hasOwnershipMarker && (parsed.action === "opened" || parsed.action === "closed");
1211
1519
  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);
1520
+ const closedAt = timestamp3(pullRequest.closed_at);
1521
+ const mergedAt = timestamp3(pullRequest.merged_at);
1214
1522
  if (parsed.action === "closed" && (pullRequest.merged ? !mergedAt : !closedAt)) {
1215
1523
  throw new Error("GitHub pull request terminal timestamp is invalid");
1216
1524
  }
@@ -1227,6 +1535,19 @@ function normalizeGitHubPullRequestOutcome(args) {
1227
1535
  updatedAt
1228
1536
  };
1229
1537
  }
1538
+ function normalizeGitHubPullRequestConversations(args) {
1539
+ const parsed = pullRequestConversationSchema.safeParse(args.body);
1540
+ const botLogin = botLoginFromEmail(args.botEmail)?.toLowerCase();
1541
+ if (!parsed.success || !botLogin) return void 0;
1542
+ if (parsed.data.pull_request.user.login.trim().toLowerCase() !== botLogin || parsed.data.sender.login.trim().toLowerCase() !== botLogin) {
1543
+ return void 0;
1544
+ }
1545
+ const conversationIds = githubConversationIds(parsed.data.pull_request.body);
1546
+ return conversationIds.length > 0 ? {
1547
+ conversationIds,
1548
+ pullRequestId: String(parsed.data.pull_request.id)
1549
+ } : void 0;
1550
+ }
1230
1551
 
1231
1552
  // src/webhooks/handler.ts
1232
1553
  function verifyGitHubSignature(body, signature, secret) {
@@ -1260,13 +1581,45 @@ function createGitHubWebhookRoute(args) {
1260
1581
  return new Response("Malformed GitHub webhook", { status: 400 });
1261
1582
  }
1262
1583
  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);
1584
+ const botEmail = args.botEmail();
1585
+ const pullRequestOutcome = eventName === "pull_request" ? normalizeGitHubPullRequestOutcome({ body, botEmail }) : void 0;
1586
+ const issueOutcome = eventName === "issues" ? normalizeGitHubIssueOutcome({ body, botEmail }) : void 0;
1587
+ const pullRequestConversations = eventName === "pull_request" ? normalizeGitHubPullRequestConversations({ body, botEmail }) : void 0;
1588
+ if (pullRequestOutcome) {
1589
+ const recordedOutcome = await recordGitHubPullRequestOutcome(
1590
+ args.db,
1591
+ pullRequestOutcome
1592
+ );
1593
+ if (recordedOutcome.applied && !recordedOutcome.commitComposition && pullRequestOutcome.state === "merged" && args.classifyPullRequestCommits) {
1594
+ let commitComposition;
1595
+ try {
1596
+ commitComposition = await args.classifyPullRequestCommits({
1597
+ number: pullRequestOutcome.number,
1598
+ repositoryFullName: pullRequestOutcome.repositoryFullName
1599
+ });
1600
+ } catch (error) {
1601
+ args.log?.error("GitHub PR commit classification failed", {
1602
+ deliveryId,
1603
+ errorType: error instanceof Error ? error.name : "UnknownError",
1604
+ number: pullRequestOutcome.number,
1605
+ repository: pullRequestOutcome.repositoryFullName
1606
+ });
1607
+ }
1608
+ if (commitComposition) {
1609
+ await recordGitHubPullRequestOutcome(args.db, {
1610
+ ...pullRequestOutcome,
1611
+ commitComposition
1612
+ });
1613
+ }
1614
+ }
1615
+ }
1616
+ if (issueOutcome) {
1617
+ await recordGitHubIssueOutcome(args.db, issueOutcome);
1269
1618
  }
1619
+ const recordedPullRequestConversations = pullRequestConversations ? await recordGitHubPullRequestConversations(
1620
+ args.db,
1621
+ pullRequestConversations
1622
+ ) : false;
1270
1623
  const resourceEvents = normalizeGitHubResourceEvents({
1271
1624
  body,
1272
1625
  deliveryId,
@@ -1275,7 +1628,7 @@ function createGitHubWebhookRoute(args) {
1275
1628
  for (const event of resourceEvents) {
1276
1629
  await args.resourceEvents.publish(event);
1277
1630
  }
1278
- if (!outcome && resourceEvents.length === 0) {
1631
+ if (!pullRequestOutcome && !issueOutcome && !recordedPullRequestConversations && resourceEvents.length === 0) {
1279
1632
  return new Response("Ignored", { status: 202 });
1280
1633
  }
1281
1634
  return new Response("Accepted", { status: 202 });
@@ -1283,32 +1636,38 @@ function createGitHubWebhookRoute(args) {
1283
1636
  };
1284
1637
  }
1285
1638
 
1286
- // src/pull-request-outcomes/report.ts
1287
- import { sql as sql2 } from "drizzle-orm";
1288
- import { z as z8 } from "zod";
1639
+ // src/outcomes/report.ts
1640
+ import { sql as sql3 } from "drizzle-orm";
1641
+ import { z as z10 } from "zod";
1289
1642
  var DAY_MS = 24 * 60 * 60 * 1e3;
1290
1643
  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()
1644
+ var pullRequestStatsSchema = z10.object({
1645
+ closed: z10.number().int().nonnegative(),
1646
+ compositionUnknown: z10.number().int().nonnegative(),
1647
+ created: z10.number().int().nonnegative(),
1648
+ days: z10.number().int().positive(),
1649
+ juniorOnly: z10.number().int().nonnegative(),
1650
+ medianMergeTimeMs: z10.number().nonnegative().nullable(),
1651
+ merged: z10.number().int().nonnegative(),
1652
+ mixed: z10.number().int().nonnegative()
1298
1653
  }).strict().transform((row) => {
1299
1654
  const terminal = row.merged + row.closed;
1655
+ const classified = row.juniorOnly + row.mixed;
1300
1656
  return {
1301
1657
  ...row,
1302
1658
  medianMergeTimeMs: row.medianMergeTimeMs ?? void 0,
1303
- mergeRate: terminal > 0 ? row.merged / terminal : void 0
1659
+ mergeRate: terminal > 0 ? row.merged / terminal : void 0,
1660
+ juniorOnlyRate: classified > 0 ? row.juniorOnly / classified : void 0
1304
1661
  };
1305
1662
  });
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)
1663
+ var pullRequestRepositoryStatsSchema = z10.object({
1664
+ closed: z10.number().int().nonnegative(),
1665
+ compositionUnknown: z10.number().int().nonnegative(),
1666
+ created: z10.number().int().nonnegative(),
1667
+ juniorOnly: z10.number().int().nonnegative(),
1668
+ merged: z10.number().int().nonnegative(),
1669
+ mixed: z10.number().int().nonnegative(),
1670
+ repository: z10.string().min(1)
1312
1671
  }).strict().transform((row) => {
1313
1672
  const terminal = row.merged + row.closed;
1314
1673
  return {
@@ -1316,19 +1675,39 @@ var repositoryStatsSchema = z8.object({
1316
1675
  mergeRate: terminal > 0 ? row.merged / terminal : void 0
1317
1676
  };
1318
1677
  });
1678
+ var issueStatsSchema = z10.object({
1679
+ closedCompleted: z10.number().int().nonnegative(),
1680
+ closedDuplicate: z10.number().int().nonnegative(),
1681
+ closedNotPlanned: z10.number().int().nonnegative(),
1682
+ closedUnknown: z10.number().int().nonnegative(),
1683
+ created: z10.number().int().nonnegative(),
1684
+ days: z10.number().int().positive(),
1685
+ medianCloseTimeMs: z10.number().nonnegative().nullable()
1686
+ }).strict().transform((row) => ({
1687
+ ...row,
1688
+ medianCloseTimeMs: row.medianCloseTimeMs ?? void 0
1689
+ }));
1690
+ var issueRepositoryStatsSchema = z10.object({
1691
+ closedCompleted: z10.number().int().nonnegative(),
1692
+ closedDuplicate: z10.number().int().nonnegative(),
1693
+ closedNotPlanned: z10.number().int().nonnegative(),
1694
+ closedUnknown: z10.number().int().nonnegative(),
1695
+ created: z10.number().int().nonnegative(),
1696
+ repository: z10.string().min(1)
1697
+ }).strict();
1319
1698
  function queryRows(result) {
1320
1699
  if (typeof result !== "object" || result === null || !("rows" in result) || !Array.isArray(result.rows)) {
1321
1700
  throw new TypeError("GitHub outcome query did not return rows");
1322
1701
  }
1323
1702
  return result.rows;
1324
1703
  }
1325
- async function aggregateOutcomeWindows(args) {
1704
+ async function aggregatePullRequestWindows(args) {
1326
1705
  const starts = WINDOWS.map(
1327
1706
  (days) => [days, new Date(args.nowMs - days * DAY_MS)]
1328
1707
  );
1329
1708
  const oldestStart = starts.at(-1)[1];
1330
1709
  const table = juniorGitHubPullRequests;
1331
- const result = await args.db.execute(sql2`
1710
+ const result = await args.db.execute(sql3`
1332
1711
  WITH windows(days, start_at) AS (
1333
1712
  VALUES
1334
1713
  (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
@@ -1338,12 +1717,12 @@ async function aggregateOutcomeWindows(args) {
1338
1717
  SELECT
1339
1718
  ${table.pullRequestId},
1340
1719
  ${table.state},
1720
+ ${table.commitComposition},
1341
1721
  ${table.openedAt},
1342
1722
  ${table.mergedAt},
1343
1723
  ${table.closedAt}
1344
1724
  FROM ${table}
1345
- WHERE ${table.state} = 'open'
1346
- OR ${table.openedAt} >= ${oldestStart}
1725
+ WHERE ${table.openedAt} >= ${oldestStart}
1347
1726
  OR ${table.mergedAt} >= ${oldestStart}
1348
1727
  OR ${table.closedAt} >= ${oldestStart}
1349
1728
  )
@@ -1363,7 +1742,23 @@ async function aggregateOutcomeWindows(args) {
1363
1742
  AND recent_pull_requests.closed_at >= windows.start_at
1364
1743
  )::integer AS "closed",
1365
1744
  count(recent_pull_requests.pull_request_id)
1366
- FILTER (WHERE recent_pull_requests.state = 'open')::integer AS "open",
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 = 'junior_only'
1749
+ )::integer AS "juniorOnly",
1750
+ count(recent_pull_requests.pull_request_id)
1751
+ FILTER (
1752
+ WHERE recent_pull_requests.state = 'merged'
1753
+ AND recent_pull_requests.merged_at >= windows.start_at
1754
+ AND recent_pull_requests.commit_composition = 'mixed'
1755
+ )::integer AS "mixed",
1756
+ count(recent_pull_requests.pull_request_id)
1757
+ FILTER (
1758
+ WHERE recent_pull_requests.state = 'merged'
1759
+ AND recent_pull_requests.merged_at >= windows.start_at
1760
+ AND recent_pull_requests.commit_composition IS NULL
1761
+ )::integer AS "compositionUnknown",
1367
1762
  (
1368
1763
  percentile_cont(0.5) WITHIN GROUP (
1369
1764
  ORDER BY extract(
@@ -1381,12 +1776,12 @@ async function aggregateOutcomeWindows(args) {
1381
1776
  GROUP BY windows.days
1382
1777
  ORDER BY windows.days
1383
1778
  `);
1384
- return z8.array(outcomeStatsSchema).parse(queryRows(result));
1779
+ return z10.array(pullRequestStatsSchema).parse(queryRows(result));
1385
1780
  }
1386
- async function aggregateRepositories(args) {
1781
+ async function aggregatePullRequestRepositories(args) {
1387
1782
  const start = new Date(args.nowMs - 30 * DAY_MS);
1388
1783
  const table = juniorGitHubPullRequests;
1389
- const result = await args.db.execute(sql2`
1784
+ const result = await args.db.execute(sql3`
1390
1785
  SELECT
1391
1786
  ${table.repositoryFullName} AS "repository",
1392
1787
  count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
@@ -1398,17 +1793,136 @@ async function aggregateRepositories(args) {
1398
1793
  WHERE ${table.state} = 'closed_unmerged'
1399
1794
  AND ${table.closedAt} >= ${start}
1400
1795
  )::integer AS "closed",
1401
- count(*) FILTER (WHERE ${table.state} = 'open')::integer AS "open"
1796
+ count(*) FILTER (
1797
+ WHERE ${table.state} = 'merged'
1798
+ AND ${table.mergedAt} >= ${start}
1799
+ AND ${table.commitComposition} = 'junior_only'
1800
+ )::integer AS "juniorOnly",
1801
+ count(*) FILTER (
1802
+ WHERE ${table.state} = 'merged'
1803
+ AND ${table.mergedAt} >= ${start}
1804
+ AND ${table.commitComposition} = 'mixed'
1805
+ )::integer AS "mixed",
1806
+ count(*) FILTER (
1807
+ WHERE ${table.state} = 'merged'
1808
+ AND ${table.mergedAt} >= ${start}
1809
+ AND ${table.commitComposition} IS NULL
1810
+ )::integer AS "compositionUnknown"
1402
1811
  FROM ${table}
1403
- WHERE ${table.state} = 'open'
1404
- OR ${table.openedAt} >= ${start}
1812
+ WHERE ${table.openedAt} >= ${start}
1405
1813
  OR ${table.mergedAt} >= ${start}
1406
1814
  OR ${table.closedAt} >= ${start}
1407
1815
  GROUP BY ${table.repositoryFullName}
1408
1816
  ORDER BY "merged" DESC, "created" DESC, "repository" ASC
1409
1817
  LIMIT 25
1410
1818
  `);
1411
- return z8.array(repositoryStatsSchema).parse(queryRows(result));
1819
+ return z10.array(pullRequestRepositoryStatsSchema).parse(queryRows(result));
1820
+ }
1821
+ async function aggregateIssueWindows(args) {
1822
+ const starts = WINDOWS.map(
1823
+ (days) => [days, new Date(args.nowMs - days * DAY_MS)]
1824
+ );
1825
+ const oldestStart = starts.at(-1)[1];
1826
+ const table = juniorGitHubIssues;
1827
+ const result = await args.db.execute(sql3`
1828
+ WITH windows(days, start_at) AS (
1829
+ VALUES
1830
+ (${starts[0][0]}::integer, ${starts[0][1]}::timestamptz),
1831
+ (${starts[1][0]}::integer, ${starts[1][1]}::timestamptz),
1832
+ (${starts[2][0]}::integer, ${starts[2][1]}::timestamptz)
1833
+ ), recent_issues AS MATERIALIZED (
1834
+ SELECT
1835
+ ${table.issueId},
1836
+ ${table.state},
1837
+ ${table.stateReason},
1838
+ ${table.openedAt},
1839
+ ${table.closedAt}
1840
+ FROM ${table}
1841
+ WHERE ${table.openedAt} >= ${oldestStart}
1842
+ OR ${table.closedAt} >= ${oldestStart}
1843
+ )
1844
+ SELECT
1845
+ windows.days AS "days",
1846
+ count(recent_issues.issue_id)
1847
+ FILTER (WHERE recent_issues.opened_at >= windows.start_at)::integer
1848
+ AS "created",
1849
+ count(recent_issues.issue_id)
1850
+ FILTER (
1851
+ WHERE recent_issues.state = 'closed'
1852
+ AND recent_issues.state_reason = 'completed'
1853
+ AND recent_issues.closed_at >= windows.start_at
1854
+ )::integer AS "closedCompleted",
1855
+ count(recent_issues.issue_id)
1856
+ FILTER (
1857
+ WHERE recent_issues.state = 'closed'
1858
+ AND recent_issues.state_reason = 'duplicate'
1859
+ AND recent_issues.closed_at >= windows.start_at
1860
+ )::integer AS "closedDuplicate",
1861
+ count(recent_issues.issue_id)
1862
+ FILTER (
1863
+ WHERE recent_issues.state = 'closed'
1864
+ AND recent_issues.state_reason = 'not_planned'
1865
+ AND recent_issues.closed_at >= windows.start_at
1866
+ )::integer AS "closedNotPlanned",
1867
+ count(recent_issues.issue_id)
1868
+ FILTER (
1869
+ WHERE recent_issues.state = 'closed'
1870
+ AND recent_issues.state_reason IS NULL
1871
+ AND recent_issues.closed_at >= windows.start_at
1872
+ )::integer AS "closedUnknown",
1873
+ (
1874
+ percentile_cont(0.5) WITHIN GROUP (
1875
+ ORDER BY extract(
1876
+ epoch FROM (recent_issues.closed_at - recent_issues.opened_at)
1877
+ ) * 1000
1878
+ ) FILTER (
1879
+ WHERE recent_issues.state = 'closed'
1880
+ AND recent_issues.closed_at >= windows.start_at
1881
+ )
1882
+ )::double precision AS "medianCloseTimeMs"
1883
+ FROM windows
1884
+ LEFT JOIN recent_issues ON true
1885
+ GROUP BY windows.days
1886
+ ORDER BY windows.days
1887
+ `);
1888
+ return z10.array(issueStatsSchema).parse(queryRows(result));
1889
+ }
1890
+ async function aggregateIssueRepositories(args) {
1891
+ const start = new Date(args.nowMs - 30 * DAY_MS);
1892
+ const table = juniorGitHubIssues;
1893
+ const result = await args.db.execute(sql3`
1894
+ SELECT
1895
+ ${table.repositoryFullName} AS "repository",
1896
+ count(*) FILTER (WHERE ${table.openedAt} >= ${start})::integer
1897
+ AS "created",
1898
+ count(*) FILTER (
1899
+ WHERE ${table.state} = 'closed'
1900
+ AND ${table.stateReason} = 'completed'
1901
+ AND ${table.closedAt} >= ${start}
1902
+ )::integer AS "closedCompleted",
1903
+ count(*) FILTER (
1904
+ WHERE ${table.state} = 'closed'
1905
+ AND ${table.stateReason} = 'duplicate'
1906
+ AND ${table.closedAt} >= ${start}
1907
+ )::integer AS "closedDuplicate",
1908
+ count(*) FILTER (
1909
+ WHERE ${table.state} = 'closed'
1910
+ AND ${table.stateReason} = 'not_planned'
1911
+ AND ${table.closedAt} >= ${start}
1912
+ )::integer AS "closedNotPlanned",
1913
+ count(*) FILTER (
1914
+ WHERE ${table.state} = 'closed'
1915
+ AND ${table.stateReason} IS NULL
1916
+ AND ${table.closedAt} >= ${start}
1917
+ )::integer AS "closedUnknown"
1918
+ FROM ${table}
1919
+ WHERE ${table.openedAt} >= ${start}
1920
+ OR ${table.closedAt} >= ${start}
1921
+ GROUP BY ${table.repositoryFullName}
1922
+ ORDER BY "created" DESC, "closedCompleted" DESC, "repository" ASC
1923
+ LIMIT 25
1924
+ `);
1925
+ return z10.array(issueRepositoryStatsSchema).parse(queryRows(result));
1412
1926
  }
1413
1927
  function formatPercent(value) {
1414
1928
  return value === void 0 ? "\u2014" : `${Math.round(value * 100)}%`;
@@ -1420,38 +1934,68 @@ function formatDuration(value) {
1420
1934
  if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
1421
1935
  return `${Math.round(hours / 24 * 10) / 10}d`;
1422
1936
  }
1937
+ function formatCommitComposition(stats) {
1938
+ return `${stats.juniorOnly} Junior-only \xB7 ${stats.mixed} mixed \xB7 ${stats.compositionUnknown} unknown`;
1939
+ }
1423
1940
  async function buildGitHubOutcomeReport(args) {
1424
- const [windows, repositories] = await Promise.all([
1425
- aggregateOutcomeWindows(args),
1426
- aggregateRepositories(args)
1941
+ const [windows, repositories, issueWindows, issueRepositories] = await Promise.all([
1942
+ aggregatePullRequestWindows(args),
1943
+ aggregatePullRequestRepositories(args),
1944
+ aggregateIssueWindows(args),
1945
+ aggregateIssueRepositories(args)
1427
1946
  ]);
1428
1947
  const thirtyDays = windows.find((window) => window.days === 30);
1948
+ const issueThirtyDays = issueWindows.find((window) => window.days === 30);
1429
1949
  return {
1430
1950
  generatedAt: new Date(args.nowMs).toISOString(),
1431
1951
  title: "GitHub work delivered",
1432
1952
  metrics: [
1433
- { label: "created \xB7 30d", value: String(thirtyDays.created) },
1953
+ { label: "PRs created \xB7 30d", value: String(thirtyDays.created) },
1434
1954
  {
1435
- label: "merged \xB7 30d",
1955
+ label: "PRs merged \xB7 30d",
1436
1956
  tone: thirtyDays.merged > 0 ? "good" : "neutral",
1437
1957
  value: String(thirtyDays.merged)
1438
1958
  },
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
1959
  {
1443
- label: "median merge time \xB7 30d",
1960
+ label: "Junior-only merge rate \xB7 30d",
1961
+ value: formatPercent(thirtyDays.juniorOnlyRate)
1962
+ },
1963
+ {
1964
+ label: "PRs closed unmerged \xB7 30d",
1965
+ value: String(thirtyDays.closed)
1966
+ },
1967
+ {
1968
+ label: "PR merge rate \xB7 30d",
1969
+ value: formatPercent(thirtyDays.mergeRate)
1970
+ },
1971
+ {
1972
+ label: "median PR merge time \xB7 30d",
1444
1973
  value: formatDuration(thirtyDays.medianMergeTimeMs)
1974
+ },
1975
+ { label: "issues created \xB7 30d", value: String(issueThirtyDays.created) },
1976
+ {
1977
+ label: "issues completed \xB7 30d",
1978
+ tone: issueThirtyDays.closedCompleted > 0 ? "good" : "neutral",
1979
+ value: String(issueThirtyDays.closedCompleted)
1980
+ },
1981
+ {
1982
+ label: "issues closed as duplicate \xB7 30d",
1983
+ value: String(issueThirtyDays.closedDuplicate)
1984
+ },
1985
+ {
1986
+ label: "issues closed as not planned \xB7 30d",
1987
+ value: String(issueThirtyDays.closedNotPlanned)
1445
1988
  }
1446
1989
  ],
1447
1990
  recordSets: [
1448
1991
  {
1449
- title: "Outcome windows",
1992
+ title: "Pull request outcome windows",
1450
1993
  fields: [
1451
1994
  { key: "window", label: "Window" },
1452
1995
  { key: "created", label: "Created" },
1453
1996
  { key: "merged", label: "Merged" },
1454
1997
  { key: "closed", label: "Closed unmerged" },
1998
+ { key: "commitComposition", label: "Merged commit composition" },
1455
1999
  { key: "mergeRate", label: "Merge rate" },
1456
2000
  { key: "mergeTime", label: "Median merge time" }
1457
2001
  ],
@@ -1462,20 +2006,21 @@ async function buildGitHubOutcomeReport(args) {
1462
2006
  created: String(stats.created),
1463
2007
  merged: String(stats.merged),
1464
2008
  closed: String(stats.closed),
2009
+ commitComposition: formatCommitComposition(stats),
1465
2010
  mergeRate: formatPercent(stats.mergeRate),
1466
2011
  mergeTime: formatDuration(stats.medianMergeTimeMs)
1467
2012
  }
1468
2013
  }))
1469
2014
  },
1470
2015
  {
1471
- title: "Repositories \xB7 30d",
2016
+ title: "Pull request repositories \xB7 30d",
1472
2017
  emptyText: "No Junior-owned pull request activity yet.",
1473
2018
  fields: [
1474
2019
  { key: "repository", label: "Repository" },
1475
2020
  { key: "created", label: "Created" },
1476
2021
  { key: "merged", label: "Merged" },
1477
2022
  { key: "closed", label: "Closed unmerged" },
1478
- { key: "open", label: "Open now" },
2023
+ { key: "commitComposition", label: "Merged commit composition" },
1479
2024
  { key: "mergeRate", label: "Merge rate" }
1480
2025
  ],
1481
2026
  records: repositories.map(({ repository, ...stats }) => ({
@@ -1485,15 +2030,115 @@ async function buildGitHubOutcomeReport(args) {
1485
2030
  created: String(stats.created),
1486
2031
  merged: String(stats.merged),
1487
2032
  closed: String(stats.closed),
1488
- open: String(stats.open),
2033
+ commitComposition: formatCommitComposition(stats),
1489
2034
  mergeRate: formatPercent(stats.mergeRate)
1490
2035
  }
1491
2036
  }))
2037
+ },
2038
+ {
2039
+ title: "Issue outcome windows",
2040
+ fields: [
2041
+ { key: "window", label: "Window" },
2042
+ { key: "created", label: "Created" },
2043
+ { key: "completed", label: "Completed" },
2044
+ { key: "duplicate", label: "Duplicate" },
2045
+ { key: "notPlanned", label: "Not planned" },
2046
+ { key: "unknown", label: "Unknown reason" },
2047
+ { key: "closeTime", label: "Median close time" }
2048
+ ],
2049
+ records: issueWindows.map((stats) => ({
2050
+ id: `${stats.days}d`,
2051
+ values: {
2052
+ window: `${stats.days} days`,
2053
+ created: String(stats.created),
2054
+ completed: String(stats.closedCompleted),
2055
+ duplicate: String(stats.closedDuplicate),
2056
+ notPlanned: String(stats.closedNotPlanned),
2057
+ unknown: String(stats.closedUnknown),
2058
+ closeTime: formatDuration(stats.medianCloseTimeMs)
2059
+ }
2060
+ }))
2061
+ },
2062
+ {
2063
+ title: "Issue repositories \xB7 30d",
2064
+ emptyText: "No Junior-owned issue activity yet.",
2065
+ fields: [
2066
+ { key: "repository", label: "Repository" },
2067
+ { key: "created", label: "Created" },
2068
+ { key: "completed", label: "Completed" },
2069
+ { key: "duplicate", label: "Duplicate" },
2070
+ { key: "notPlanned", label: "Not planned" },
2071
+ { key: "unknown", label: "Unknown reason" }
2072
+ ],
2073
+ records: issueRepositories.map(({ repository, ...stats }) => ({
2074
+ id: repository,
2075
+ values: {
2076
+ repository,
2077
+ created: String(stats.created),
2078
+ completed: String(stats.closedCompleted),
2079
+ duplicate: String(stats.closedDuplicate),
2080
+ notPlanned: String(stats.closedNotPlanned),
2081
+ unknown: String(stats.closedUnknown)
2082
+ }
2083
+ }))
1492
2084
  }
1493
2085
  ]
1494
2086
  };
1495
2087
  }
1496
2088
 
2089
+ // src/pull-request-outcomes/commit-composition.ts
2090
+ import { z as z11 } from "zod";
2091
+ var canonicalCommitSchema = z11.object({
2092
+ authorEmail: z11.string().nullable(),
2093
+ authorLogin: z11.string().nullable()
2094
+ }).strict();
2095
+ var providerCommitSchema = z11.object({
2096
+ author: z11.object({ login: z11.string() }).passthrough().nullable(),
2097
+ commit: z11.object({
2098
+ author: z11.object({ email: z11.string() }).passthrough().nullable()
2099
+ }).passthrough()
2100
+ }).passthrough();
2101
+ var commitPageSchema = z11.array(providerCommitSchema).transform(
2102
+ (commits) => commits.map(
2103
+ (commit) => canonicalCommitSchema.parse({
2104
+ authorEmail: commit.commit.author?.email ?? null,
2105
+ authorLogin: commit.author?.login ?? null
2106
+ })
2107
+ )
2108
+ );
2109
+ var PAGE_SIZE = 100;
2110
+ var MAX_PAGES = 3;
2111
+ var MAX_LISTED_COMMITS = 250;
2112
+ async function classifyGitHubPullRequestCommitComposition(args) {
2113
+ const botEmail = args.botEmail.trim().toLowerCase();
2114
+ const botLogin = botLoginFromEmail(botEmail)?.toLowerCase();
2115
+ if (!botEmail || !botLogin) {
2116
+ throw new Error(
2117
+ "The configured GitHub App bot email cannot classify pull request commits"
2118
+ );
2119
+ }
2120
+ let foundCommit = false;
2121
+ let inspectedCommits = 0;
2122
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
2123
+ const commits = commitPageSchema.parse(
2124
+ await args.loadPage(page, PAGE_SIZE)
2125
+ );
2126
+ inspectedCommits += commits.length;
2127
+ for (const commit of commits) {
2128
+ foundCommit = true;
2129
+ const authorLogin = commit.authorLogin?.trim().toLowerCase();
2130
+ const authorEmail = commit.authorEmail?.trim().toLowerCase();
2131
+ if (authorLogin !== botLogin && authorEmail !== botEmail) {
2132
+ return "mixed";
2133
+ }
2134
+ }
2135
+ if (inspectedCommits >= MAX_LISTED_COMMITS) return void 0;
2136
+ if (commits.length < PAGE_SIZE) break;
2137
+ if (page === MAX_PAGES) return void 0;
2138
+ }
2139
+ return foundCommit ? "junior_only" : void 0;
2140
+ }
2141
+
1497
2142
  // src/index.ts
1498
2143
  var GITHUB_APP_ID_ENV = "GITHUB_APP_ID";
1499
2144
  var GITHUB_APP_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY";
@@ -2059,28 +2704,6 @@ function githubRepositoryFromLeaseScope(leaseScope) {
2059
2704
  }
2060
2705
  return { owner: match[1], name: match[2] };
2061
2706
  }
2062
- function githubRepositoryIdFromUploadUrl(upstreamUrl) {
2063
- const repositoryId = Number(upstreamUrl.searchParams.get("repository_id"));
2064
- if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0) {
2065
- throw new EgressPolicyDenied(
2066
- "GitHub asset upload request is missing a valid repository_id."
2067
- );
2068
- }
2069
- return repositoryId;
2070
- }
2071
- function githubRepositoryIdLeaseScope(repositoryId) {
2072
- return `repository-id:${repositoryId}`;
2073
- }
2074
- function githubRepositoryIdFromLeaseScope(leaseScope) {
2075
- const match = /^repository-id:(\d+)$/.exec(leaseScope ?? "");
2076
- const repositoryId = Number(match?.[1]);
2077
- if (!Number.isSafeInteger(repositoryId) || repositoryId <= 0) {
2078
- throw new GitHubPluginSetupError(
2079
- "GitHub asset upload grant is missing a repository id lease scope."
2080
- );
2081
- }
2082
- return repositoryId;
2083
- }
2084
2707
  async function resolveUserAccount(tokens) {
2085
2708
  const account = await githubRequest("https://api.github.com", "/user", {
2086
2709
  token: tokens.accessToken
@@ -2250,7 +2873,7 @@ async function issueUserCredential(ctx, options) {
2250
2873
  }
2251
2874
  return credentialNeeded("Your GitHub authorization has expired.", scope);
2252
2875
  }
2253
- async function issueInstallationCredential(options) {
2876
+ async function issueInstallationToken(options) {
2254
2877
  const appId = requireEnv(options.appIdEnv);
2255
2878
  const installationIdRaw = requireEnv(options.installationIdEnv);
2256
2879
  const installationId = Number(installationIdRaw);
@@ -2258,8 +2881,10 @@ async function issueInstallationCredential(options) {
2258
2881
  throw new GitHubPluginSetupError(`Invalid ${options.installationIdEnv}`);
2259
2882
  }
2260
2883
  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 })
2884
+ const permissions = "permissions" in options ? options.permissions : typeof options.loadPermissions === "function" ? await options.loadPermissions({ appJwt, installationId }) : void 0;
2885
+ const body = {
2886
+ ...permissions ? { permissions } : {},
2887
+ ..."repositories" in options ? { repositories: options.repositories } : {}
2263
2888
  };
2264
2889
  const accessTokenResponse = await githubRequest(
2265
2890
  "https://api.github.com",
@@ -2271,14 +2896,16 @@ async function issueInstallationCredential(options) {
2271
2896
  }
2272
2897
  );
2273
2898
  const parsedToken = parseInstallationTokenResponse(accessTokenResponse);
2274
- const expiresAtMs = Math.min(
2275
- parsedToken.expiresAtMs,
2276
- Date.now() + MAX_LEASE_MS
2277
- );
2899
+ return {
2900
+ expiresAtMs: Math.min(parsedToken.expiresAtMs, Date.now() + MAX_LEASE_MS),
2901
+ token: parsedToken.token
2902
+ };
2903
+ }
2904
+ async function issueInstallationCredential(options) {
2905
+ const token = await issueInstallationToken(options);
2278
2906
  return createCredentialLease({
2279
- ...options.domains ? { domains: options.domains } : {},
2280
- token: parsedToken.token,
2281
- expiresAtMs
2907
+ token: token.token,
2908
+ expiresAtMs: token.expiresAtMs
2282
2909
  });
2283
2910
  }
2284
2911
  function createPermissionCache() {
@@ -2595,13 +3222,7 @@ async function githubGrantForEgress(ctx) {
2595
3222
  upstreamUrl
2596
3223
  });
2597
3224
  if (isGitHubAssetUploadRequest(method, upstreamUrl)) {
2598
- const repositoryId = githubRepositoryIdFromUploadUrl(upstreamUrl);
2599
- return grantForAccess(
2600
- "write",
2601
- "github.asset-upload",
2602
- "installation-write",
2603
- githubRepositoryIdLeaseScope(repositoryId)
2604
- );
3225
+ return grantForAccess("write", "github.asset-upload", "user-write");
2605
3226
  }
2606
3227
  const smartHttpAccess = githubSmartHttpAccess(upstreamUrl);
2607
3228
  if (smartHttpAccess) {
@@ -2725,7 +3346,34 @@ function githubPlugin(options = {}) {
2725
3346
  return [
2726
3347
  createGitHubWebhookRoute({
2727
3348
  botEmail: () => readEnv(botEmailEnv),
3349
+ classifyPullRequestCommits: async ({
3350
+ number,
3351
+ repositoryFullName
3352
+ }) => {
3353
+ const [owner, name, ...extra] = repositoryFullName.split("/");
3354
+ if (!owner || !name || extra.length > 0) {
3355
+ throw new Error(
3356
+ "GitHub pull request commit classification received an invalid repository name"
3357
+ );
3358
+ }
3359
+ const token = await issueInstallationToken({
3360
+ appIdEnv,
3361
+ privateKeyEnv,
3362
+ installationIdEnv,
3363
+ permissions: { pull_requests: "read" },
3364
+ repositories: [name]
3365
+ });
3366
+ return await classifyGitHubPullRequestCommitComposition({
3367
+ botEmail: requireEnv(botEmailEnv),
3368
+ loadPage: async (page, perPage) => await githubRequest(
3369
+ "https://api.github.com",
3370
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls/${number}/commits?per_page=${perPage}&page=${page}`,
3371
+ { token: token.token }
3372
+ )
3373
+ });
3374
+ },
2728
3375
  db: ctx.db,
3376
+ log: ctx.log,
2729
3377
  resourceEvents: ctx.resourceEvents,
2730
3378
  webhookSecret: () => readEnv("GITHUB_WEBHOOK_SECRET")
2731
3379
  })
@@ -2808,18 +3456,6 @@ function githubPlugin(options = {}) {
2808
3456
  });
2809
3457
  }
2810
3458
  if (ctx.grant.name === "installation-write") {
2811
- if (ctx.grant.reason === "github.asset-upload") {
2812
- const repositoryId = githubRepositoryIdFromLeaseScope(
2813
- ctx.grant.leaseScope
2814
- );
2815
- return await issueInstallationCredential({
2816
- appIdEnv,
2817
- domains: GITHUB_ASSET_UPLOAD_CREDENTIAL_DOMAINS,
2818
- privateKeyEnv,
2819
- installationIdEnv,
2820
- repositoryIds: [repositoryId]
2821
- });
2822
- }
2823
3459
  const repository = githubRepositoryFromLeaseScope(
2824
3460
  ctx.grant.leaseScope
2825
3461
  );