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