@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/README.md +3 -3
- package/SETUP.md +11 -6
- package/dist/db/database.d.ts +5 -0
- package/dist/db/schema.d.ts +425 -0
- package/dist/index.js +706 -91
- package/dist/issue-outcomes/store.d.ts +24 -0
- package/dist/{pull-request-outcomes → outcomes}/report.d.ts +2 -2
- package/dist/pull-request-outcomes/commit-composition.d.ts +6 -0
- package/dist/pull-request-outcomes/store.d.ts +18 -5
- package/dist/tools/footer.d.ts +2 -0
- package/dist/webhooks/handler.d.ts +8 -2
- package/dist/webhooks/issue-outcome.d.ts +6 -0
- package/dist/webhooks/ownership.d.ts +2 -0
- package/dist/webhooks/pull-request-outcome.d.ts +6 -1
- package/migrations/0001_issue_outcomes.sql +14 -0
- package/migrations/0002_pull_request_commit_composition.sql +1 -0
- package/migrations/0003_pull_request_conversations.sql +1 -0
- package/migrations/meta/0001_snapshot.json +256 -0
- package/migrations/meta/0002_snapshot.json +262 -0
- package/migrations/meta/0003_snapshot.json +269 -0
- package/migrations/meta/_journal.json +21 -0
- package/package.json +2 -2
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/
|
|
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/
|
|
1107
|
-
var
|
|
1157
|
+
// src/issue-outcomes/store.ts
|
|
1158
|
+
var githubIssueOutcomeInputSchema = z6.object({
|
|
1108
1159
|
candidateOwned: z6.boolean(),
|
|
1109
1160
|
closedAt: z6.date().optional(),
|
|
1110
|
-
|
|
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:
|
|
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
|
|
1132
|
-
const outcome =
|
|
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(
|
|
1184
|
+
await db.update(juniorGitHubIssues).set(values).where(
|
|
1136
1185
|
and(
|
|
1137
|
-
eq(
|
|
1138
|
-
lte(
|
|
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(
|
|
1144
|
-
target:
|
|
1192
|
+
await db.insert(juniorGitHubIssues).values({ issueId: outcome.issueId, ...values }).onConflictDoUpdate({
|
|
1193
|
+
target: juniorGitHubIssues.issueId,
|
|
1145
1194
|
set: values,
|
|
1146
|
-
where: lte(
|
|
1195
|
+
where: lte(juniorGitHubIssues.updatedAt, outcome.updatedAt)
|
|
1147
1196
|
});
|
|
1148
1197
|
}
|
|
1149
1198
|
|
|
1150
|
-
// src/
|
|
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
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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 =
|
|
1196
|
-
const updatedAt =
|
|
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 =
|
|
1213
|
-
const mergedAt =
|
|
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
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}) : void 0;
|
|
1267
|
-
if (
|
|
1268
|
-
await recordGitHubPullRequestOutcome(
|
|
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 (!
|
|
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/
|
|
1287
|
-
import { sql as
|
|
1288
|
-
import { z as
|
|
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
|
|
1292
|
-
closed:
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
1307
|
-
closed:
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
|
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(
|
|
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
|
|
1767
|
+
return z10.array(pullRequestStatsSchema).parse(queryRows(result));
|
|
1385
1768
|
}
|
|
1386
|
-
async function
|
|
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(
|
|
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
|
|
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
|
-
|
|
1426
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
2262
|
-
|
|
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
|
-
|
|
2275
|
-
parsedToken.expiresAtMs,
|
|
2276
|
-
|
|
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:
|
|
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
|
})
|