@reviewflow/sdk 6.21.0 → 6.23.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.
@@ -1023,9 +1023,7 @@ const restComment = (comment) => ({
1023
1023
  line: comment.line,
1024
1024
  side: comment.side,
1025
1025
  ...comment.startLine === null || comment.startSide === null ? {} : {
1026
- // eslint-disable-next-line camelcase -- GitHub's field name
1027
1026
  start_line: comment.startLine,
1028
- // eslint-disable-next-line camelcase -- GitHub's field name
1029
1027
  start_side: comment.startSide
1030
1028
  }
1031
1029
  });
@@ -1091,7 +1089,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1091
1089
  await transport.rest({
1092
1090
  method: "POST",
1093
1091
  path: `${pullPath}/requested_reviewers`,
1094
- // eslint-disable-next-line camelcase -- GitHub's field name
1095
1092
  body: { reviewers: edit.add, team_reviewers: edit.addTeams }
1096
1093
  });
1097
1094
  }
@@ -1099,7 +1096,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1099
1096
  await transport.rest({
1100
1097
  method: "DELETE",
1101
1098
  path: `${pullPath}/requested_reviewers`,
1102
- // eslint-disable-next-line camelcase -- GitHub's field name
1103
1099
  body: { reviewers: edit.remove, team_reviewers: edit.removeTeams }
1104
1100
  });
1105
1101
  }
@@ -1147,13 +1143,10 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1147
1143
  method: "PUT",
1148
1144
  path: `${pullPath}/merge`,
1149
1145
  body: {
1150
- // eslint-disable-next-line camelcase -- GitHub's field name
1151
1146
  merge_method: edit.method,
1152
1147
  sha: edit.expectedHeadSha,
1153
1148
  ...edit.commitMessage === void 0 ? {} : {
1154
- // eslint-disable-next-line camelcase -- GitHub's field name
1155
1149
  commit_title: edit.commitMessage.title,
1156
- // eslint-disable-next-line camelcase -- GitHub's field name
1157
1150
  commit_message: edit.commitMessage.body
1158
1151
  }
1159
1152
  }
@@ -1164,7 +1157,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1164
1157
  await transport.rest({
1165
1158
  method: "PUT",
1166
1159
  path: `${pullPath}/update-branch`,
1167
- // eslint-disable-next-line camelcase -- GitHub's field name
1168
1160
  body: { expected_head_sha: edit.expectedHeadSha }
1169
1161
  });
1170
1162
  return null;
@@ -1244,7 +1236,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1244
1236
  method: "POST",
1245
1237
  path: `${pullPath}/reviews`,
1246
1238
  body: {
1247
- // eslint-disable-next-line camelcase -- GitHub's field name
1248
1239
  commit_id: edit.commitId,
1249
1240
  ...edit.body === "" ? {} : { body: edit.body },
1250
1241
  event: reviewEventNames[edit.event],
@@ -1264,7 +1255,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1264
1255
  await transport.rest({
1265
1256
  method: "POST",
1266
1257
  path: `${pullPath}/comments`,
1267
- // eslint-disable-next-line camelcase -- GitHub's field name
1268
1258
  body: { commit_id: edit.commitId, ...restComment(edit.comment) }
1269
1259
  });
1270
1260
  return null;
@@ -2317,6 +2307,75 @@ const createGitHubTransport = (send) => ({
2317
2307
  }
2318
2308
  });
2319
2309
 
2310
+ const prStatuses = [
2311
+ "approved",
2312
+ "changesRequested",
2313
+ "closed",
2314
+ "draft",
2315
+ "merged",
2316
+ "noReviewers",
2317
+ "waitingReview"
2318
+ ];
2319
+ const reviewStatuses = {
2320
+ approved: "approved",
2321
+ changesRequested: "changesRequested",
2322
+ reviewRequired: "waitingReview"
2323
+ };
2324
+ const prStatus = ({
2325
+ state,
2326
+ reviewDecision
2327
+ }) => {
2328
+ if (state !== "open") return state;
2329
+ return reviewDecision === null ? "noReviewers" : reviewStatuses[reviewDecision];
2330
+ };
2331
+ const prStatusLabels = {
2332
+ approved: "approved",
2333
+ changesRequested: "changes requested",
2334
+ closed: "closed",
2335
+ draft: "draft",
2336
+ merged: "merged",
2337
+ noReviewers: "no reviewers",
2338
+ waitingReview: "waiting review"
2339
+ };
2340
+ const prHasConflicts = ({
2341
+ state,
2342
+ mergeable
2343
+ }) => (state === "open" || state === "draft") && mergeable === "conflicting";
2344
+ const prUrl = (ownerRepo, number) => `https://github.com/${ownerRepo}/pull/${String(number)}`;
2345
+ const prNumberPattern = /^#?(\d+)$/;
2346
+ const prUrlPattern = /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#]\S*)?$/i;
2347
+ const prNumberOf = (input, ownerRepo) => {
2348
+ const text = input.trim();
2349
+ const url = prUrlPattern.exec(text);
2350
+ const [, digits] = url === null ? prNumberPattern.exec(text) ?? [] : [];
2351
+ if (url !== null && url[1]?.toLowerCase() !== ownerRepo.toLowerCase()) {
2352
+ return `that pull request is not in ${ownerRepo}`;
2353
+ }
2354
+ const number = Number(url?.[2] ?? digits);
2355
+ if (!Number.isInteger(number) || number < 1) {
2356
+ return "a pull request number or its GitHub link is expected";
2357
+ }
2358
+ return number;
2359
+ };
2360
+ const reviewflowReviewDecisions = {
2361
+ approved: "approved",
2362
+ changesRequested: "changesRequested",
2363
+ requested: "reviewRequired",
2364
+ none: null
2365
+ };
2366
+ const reviewflowPrStatus = ({
2367
+ state,
2368
+ reviewState
2369
+ }) => {
2370
+ if (state === null) return null;
2371
+ if (state !== "open") return state;
2372
+ if (reviewState === null) return null;
2373
+ return prStatus({
2374
+ state,
2375
+ reviewDecision: reviewflowReviewDecisions[reviewState]
2376
+ });
2377
+ };
2378
+
2320
2379
  const pullRequestParts = [
2321
2380
  "detail",
2322
2381
  "checks",
@@ -2401,6 +2460,32 @@ const pullRequestEditMovesBoard = (edit) => {
2401
2460
  }
2402
2461
  };
2403
2462
 
2463
+ const dayMs = 24 * 60 * 60 * 1e3;
2464
+ const weekdayReach = 6;
2465
+ const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
2466
+ const calendarDaysAgo = (date, now) => Math.round((startOfDay(now) - startOfDay(date)) / dayMs);
2467
+ const formatRelativeDate = (value, { now = /* @__PURE__ */ new Date(), locale } = {}) => {
2468
+ const date = typeof value === "string" ? new Date(value) : value;
2469
+ if (Number.isNaN(date.getTime())) return "unknown date";
2470
+ const time = date.toLocaleTimeString(locale, {
2471
+ hour: "2-digit",
2472
+ minute: "2-digit"
2473
+ });
2474
+ const days = calendarDaysAgo(date, now);
2475
+ if (days === 0) return `today ${time}`;
2476
+ if (days === 1) return `yesterday ${time}`;
2477
+ if (days > 1 && days <= weekdayReach) {
2478
+ const weekday = date.toLocaleDateString("en", { weekday: "long" });
2479
+ return `last ${weekday} ${time}`;
2480
+ }
2481
+ const day = date.toLocaleDateString(locale, {
2482
+ day: "numeric",
2483
+ month: "short",
2484
+ ...date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }
2485
+ });
2486
+ return `${day}, ${time}`;
2487
+ };
2488
+
2404
2489
  const partFields = {
2405
2490
  detail: (pr) => [
2406
2491
  pr.title,
@@ -3074,6 +3159,92 @@ const applyStreamEvent = (snapshot, event) => {
3074
3159
  };
3075
3160
  };
3076
3161
 
3162
+ const maxFailedCheckNames = 9;
3163
+ const bucketsSilentOnPassedChecks = /* @__PURE__ */ new Set(["changes-requested", "drafts"]);
3164
+ const reRequestedBucket = "re-requested-reviews";
3165
+ const requestingAttentionGroup = "requesting-attention";
3166
+ const counted = (count, one, many) => `${String(count)} ${count === 1 ? one : many}`;
3167
+ const people = (logins, viewer) => logins.map((login) => login === viewer ? "you" : `@${login}`).join(", ");
3168
+ const failedChecksText = ({
3169
+ checksConclusion,
3170
+ failedChecks
3171
+ }) => {
3172
+ if (failedChecks.length === 0) {
3173
+ return checksConclusion === "failed" ? "checks failed" : null;
3174
+ }
3175
+ const names = failedChecks.slice(0, maxFailedCheckNames).join(", ");
3176
+ const remaining = failedChecks.length - maxFailedCheckNames;
3177
+ const more = remaining > 0 ? ` +${String(remaining)}` : "";
3178
+ return `${failedChecks.length === 1 ? "check" : "checks"} failed: ${names}${more}`;
3179
+ };
3180
+ const reviewflowPrFailure = (pr) => {
3181
+ const parts = [
3182
+ failedChecksText(pr),
3183
+ pr.lintFailed ? "pr lint failed" : null,
3184
+ pr.mergeable === "conflicting" ? "conflicts" : null
3185
+ ].filter((part) => part !== null);
3186
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3187
+ };
3188
+ const reviewflowPrChangesRequested = ({ changesRequestedBy }, viewer) => changesRequestedBy.length === 0 ? null : `changes requested by ${people(changesRequestedBy, viewer)}`;
3189
+ const checksProgressText = ({
3190
+ checksConclusion,
3191
+ checksRunning
3192
+ }, bucketKey) => {
3193
+ if (checksRunning > 0) {
3194
+ return `${counted(checksRunning, "check", "checks")} running`;
3195
+ }
3196
+ return checksConclusion === "passed" && !bucketsSilentOnPassedChecks.has(bucketKey) ? "checks passed" : null;
3197
+ };
3198
+ const awaitedText = ({
3199
+ requestedReviewers,
3200
+ reRequestedReviewers,
3201
+ requestedTeams
3202
+ }, { bucketKey, groupKey, viewer }) => {
3203
+ const reviewers = [
3204
+ ...requestedReviewers,
3205
+ ...bucketKey === reRequestedBucket ? [] : reRequestedReviewers
3206
+ ];
3207
+ if (reviewers.length === 0 && requestedTeams.length === 0) return null;
3208
+ const verb = groupKey === requestingAttentionGroup ? "requested" : "awaiting";
3209
+ const awaited = [
3210
+ ...reviewers.length === 0 ? [] : [people(reviewers, viewer)],
3211
+ ...requestedTeams.map((team) => `#${team}`)
3212
+ ];
3213
+ return `${verb} ${awaited.join(", ")}`;
3214
+ };
3215
+ const reviewflowPrProgress = (pr, context) => {
3216
+ const parts = [
3217
+ checksProgressText(pr, context.bucketKey),
3218
+ pr.approvedBy.length === 0 ? null : `approved by ${people(pr.approvedBy, context.viewer)}`,
3219
+ pr.unresolvedThreads ? counted(pr.unresolvedThreads, "unresolved thread", "unresolved threads") : null,
3220
+ awaitedText(pr, context)
3221
+ ].filter((part) => part !== null);
3222
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3223
+ };
3224
+ const reviewflowPrOwners = ({ author, assignees }, viewer) => {
3225
+ const named = [...author === null ? [] : [author], ...assignees];
3226
+ if (named.length === 0) return null;
3227
+ if (viewer !== null && named.every((login) => login === viewer)) return null;
3228
+ const byline = author === null ? null : `by ${people([author], viewer)}`;
3229
+ if (assignees.length === 0) return byline;
3230
+ const assignment = `assigned to ${people(assignees, viewer)}`;
3231
+ if (author === null || !assignees.includes(author)) {
3232
+ return byline === null ? assignment : `${byline} \xB7 ${assignment}`;
3233
+ }
3234
+ return assignees.length === 1 ? byline : assignment;
3235
+ };
3236
+ const reviewflowPrFlowDate = ({ openedAt, approvedAt }, options) => {
3237
+ if (approvedAt !== null) {
3238
+ return `approved ${formatRelativeDate(approvedAt, options)}`;
3239
+ }
3240
+ return openedAt === null ? null : `opened ${formatRelativeDate(openedAt, options)}`;
3241
+ };
3242
+ const formatPrChanges = ({
3243
+ changedFiles,
3244
+ additions,
3245
+ deletions
3246
+ }) => `${counted(changedFiles, "file", "files")} +${String(additions)} \u2212${String(deletions)}`;
3247
+
3077
3248
  const lingerMs = 5 * 6e4;
3078
3249
  const boardReadDelayMs = 3e3;
3079
3250
  const reconnectDelayMs = 1e3;
@@ -3334,5 +3505,5 @@ const insertCheckoutEntry = (source, ownerRepo) => {
3334
3505
  return { source: text, cursor: value };
3335
3506
  };
3336
3507
 
3337
- export { accountOfOwnerRepo, applyPullRequestEdit, applyStreamEvent, applySuggestionsToText, assembleBoard, boardReadDelayMs, buildPrQuery, changedPullRequestParts, checkOutcomes, checksFromReviewflow, coAuthorTrailers, commitSuggestions, createGitHubTransport, createReviewflowClient, createReviewflowFollower, diffLines, diffPaths, diffThemeNames, diffThemeOptions, diffThemes, editPullRequest, fail, fetchCompareDiff, fetchPullRequestCandidates, fetchPullRequestHead, fetchPullRequestPart, fetchPullRequestRevisions, insertCheckoutEntry, interpretGraphql, isAttachablePath, isDiffTheme, isDiffWhitespace, isPullRequestOnBoard, isPullRequestPart, isPullRequestReactionContent, isReviewflowFailure, lingerMs, maxExtraPagesPerBucket, maxLookupTargets, maxRetryMs, parsePrQueryResponse, parsePullRequestCandidates, parsePullRequestChecks, parsePullRequestCommits, parsePullRequestDetail, parsePullRequestEdit, parsePullRequestFiles, parsePullRequestRevisions, parsePullRequestTimeline, parseSuggestions, partFingerprint, planSuggestions, prFields, pullRequestCandidatesQuery, pullRequestChecksQuery, pullRequestCommitsQuery, pullRequestDetailQuery, pullRequestEditMovesBoard, pullRequestFilesQuery, pullRequestHeadQuery, pullRequestParts, pullRequestPartsStaleAfter, pullRequestReactionContents, pullRequestRevisionsQuery, pullRequestTimelineQuery, readAccountBoards, readPrDetail, readReviewflowStream, reconnectDelayMs, responseFailure, restErrorMessage, reviewflowErrorCode, splitOwnerRepo, streamClosedWithinDays, streamIdleTimeoutMs, subjectsOf, suggestionCommitRefusal, suggestionOriginalLines, suggestionRangesOverlap, toBoardPr, toFetchedPr, withAccountBoard, withFileViewed };
3508
+ export { accountOfOwnerRepo, applyPullRequestEdit, applyStreamEvent, applySuggestionsToText, assembleBoard, boardReadDelayMs, buildPrQuery, changedPullRequestParts, checkOutcomes, checksFromReviewflow, coAuthorTrailers, commitSuggestions, createGitHubTransport, createReviewflowClient, createReviewflowFollower, diffLines, diffPaths, diffThemeNames, diffThemeOptions, diffThemes, editPullRequest, fail, fetchCompareDiff, fetchPullRequestCandidates, fetchPullRequestHead, fetchPullRequestPart, fetchPullRequestRevisions, formatPrChanges, formatRelativeDate, insertCheckoutEntry, interpretGraphql, isAttachablePath, isDiffTheme, isDiffWhitespace, isPullRequestOnBoard, isPullRequestPart, isPullRequestReactionContent, isReviewflowFailure, lingerMs, maxExtraPagesPerBucket, maxFailedCheckNames, maxLookupTargets, maxRetryMs, parsePrQueryResponse, parsePullRequestCandidates, parsePullRequestChecks, parsePullRequestCommits, parsePullRequestDetail, parsePullRequestEdit, parsePullRequestFiles, parsePullRequestRevisions, parsePullRequestTimeline, parseSuggestions, partFingerprint, planSuggestions, prFields, prHasConflicts, prNumberOf, prStatus, prStatusLabels, prStatuses, prUrl, pullRequestCandidatesQuery, pullRequestChecksQuery, pullRequestCommitsQuery, pullRequestDetailQuery, pullRequestEditMovesBoard, pullRequestFilesQuery, pullRequestHeadQuery, pullRequestParts, pullRequestPartsStaleAfter, pullRequestReactionContents, pullRequestRevisionsQuery, pullRequestTimelineQuery, readAccountBoards, readPrDetail, readReviewflowStream, reconnectDelayMs, responseFailure, restErrorMessage, reviewflowErrorCode, reviewflowPrChangesRequested, reviewflowPrFailure, reviewflowPrFlowDate, reviewflowPrOwners, reviewflowPrProgress, reviewflowPrStatus, reviewflowReviewDecisions, splitOwnerRepo, streamClosedWithinDays, streamIdleTimeoutMs, subjectsOf, suggestionCommitRefusal, suggestionOriginalLines, suggestionRangesOverlap, toBoardPr, toFetchedPr, withAccountBoard, withFileViewed };
3338
3509
  //# sourceMappingURL=index-browser.es.js.map
@@ -1023,9 +1023,7 @@ const restComment = (comment) => ({
1023
1023
  line: comment.line,
1024
1024
  side: comment.side,
1025
1025
  ...comment.startLine === null || comment.startSide === null ? {} : {
1026
- // eslint-disable-next-line camelcase -- GitHub's field name
1027
1026
  start_line: comment.startLine,
1028
- // eslint-disable-next-line camelcase -- GitHub's field name
1029
1027
  start_side: comment.startSide
1030
1028
  }
1031
1029
  });
@@ -1091,7 +1089,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1091
1089
  await transport.rest({
1092
1090
  method: "POST",
1093
1091
  path: `${pullPath}/requested_reviewers`,
1094
- // eslint-disable-next-line camelcase -- GitHub's field name
1095
1092
  body: { reviewers: edit.add, team_reviewers: edit.addTeams }
1096
1093
  });
1097
1094
  }
@@ -1099,7 +1096,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1099
1096
  await transport.rest({
1100
1097
  method: "DELETE",
1101
1098
  path: `${pullPath}/requested_reviewers`,
1102
- // eslint-disable-next-line camelcase -- GitHub's field name
1103
1099
  body: { reviewers: edit.remove, team_reviewers: edit.removeTeams }
1104
1100
  });
1105
1101
  }
@@ -1147,13 +1143,10 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1147
1143
  method: "PUT",
1148
1144
  path: `${pullPath}/merge`,
1149
1145
  body: {
1150
- // eslint-disable-next-line camelcase -- GitHub's field name
1151
1146
  merge_method: edit.method,
1152
1147
  sha: edit.expectedHeadSha,
1153
1148
  ...edit.commitMessage === void 0 ? {} : {
1154
- // eslint-disable-next-line camelcase -- GitHub's field name
1155
1149
  commit_title: edit.commitMessage.title,
1156
- // eslint-disable-next-line camelcase -- GitHub's field name
1157
1150
  commit_message: edit.commitMessage.body
1158
1151
  }
1159
1152
  }
@@ -1164,7 +1157,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1164
1157
  await transport.rest({
1165
1158
  method: "PUT",
1166
1159
  path: `${pullPath}/update-branch`,
1167
- // eslint-disable-next-line camelcase -- GitHub's field name
1168
1160
  body: { expected_head_sha: edit.expectedHeadSha }
1169
1161
  });
1170
1162
  return null;
@@ -1244,7 +1236,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1244
1236
  method: "POST",
1245
1237
  path: `${pullPath}/reviews`,
1246
1238
  body: {
1247
- // eslint-disable-next-line camelcase -- GitHub's field name
1248
1239
  commit_id: edit.commitId,
1249
1240
  ...edit.body === "" ? {} : { body: edit.body },
1250
1241
  event: reviewEventNames[edit.event],
@@ -1264,7 +1255,6 @@ const editPullRequest = async (transport, { ownerRepo, number, edit }, options =
1264
1255
  await transport.rest({
1265
1256
  method: "POST",
1266
1257
  path: `${pullPath}/comments`,
1267
- // eslint-disable-next-line camelcase -- GitHub's field name
1268
1258
  body: { commit_id: edit.commitId, ...restComment(edit.comment) }
1269
1259
  });
1270
1260
  return null;
@@ -2317,6 +2307,75 @@ const createGitHubTransport = (send) => ({
2317
2307
  }
2318
2308
  });
2319
2309
 
2310
+ const prStatuses = [
2311
+ "approved",
2312
+ "changesRequested",
2313
+ "closed",
2314
+ "draft",
2315
+ "merged",
2316
+ "noReviewers",
2317
+ "waitingReview"
2318
+ ];
2319
+ const reviewStatuses = {
2320
+ approved: "approved",
2321
+ changesRequested: "changesRequested",
2322
+ reviewRequired: "waitingReview"
2323
+ };
2324
+ const prStatus = ({
2325
+ state,
2326
+ reviewDecision
2327
+ }) => {
2328
+ if (state !== "open") return state;
2329
+ return reviewDecision === null ? "noReviewers" : reviewStatuses[reviewDecision];
2330
+ };
2331
+ const prStatusLabels = {
2332
+ approved: "approved",
2333
+ changesRequested: "changes requested",
2334
+ closed: "closed",
2335
+ draft: "draft",
2336
+ merged: "merged",
2337
+ noReviewers: "no reviewers",
2338
+ waitingReview: "waiting review"
2339
+ };
2340
+ const prHasConflicts = ({
2341
+ state,
2342
+ mergeable
2343
+ }) => (state === "open" || state === "draft") && mergeable === "conflicting";
2344
+ const prUrl = (ownerRepo, number) => `https://github.com/${ownerRepo}/pull/${String(number)}`;
2345
+ const prNumberPattern = /^#?(\d+)$/;
2346
+ const prUrlPattern = /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#]\S*)?$/i;
2347
+ const prNumberOf = (input, ownerRepo) => {
2348
+ const text = input.trim();
2349
+ const url = prUrlPattern.exec(text);
2350
+ const [, digits] = url === null ? prNumberPattern.exec(text) ?? [] : [];
2351
+ if (url !== null && url[1]?.toLowerCase() !== ownerRepo.toLowerCase()) {
2352
+ return `that pull request is not in ${ownerRepo}`;
2353
+ }
2354
+ const number = Number(url?.[2] ?? digits);
2355
+ if (!Number.isInteger(number) || number < 1) {
2356
+ return "a pull request number or its GitHub link is expected";
2357
+ }
2358
+ return number;
2359
+ };
2360
+ const reviewflowReviewDecisions = {
2361
+ approved: "approved",
2362
+ changesRequested: "changesRequested",
2363
+ requested: "reviewRequired",
2364
+ none: null
2365
+ };
2366
+ const reviewflowPrStatus = ({
2367
+ state,
2368
+ reviewState
2369
+ }) => {
2370
+ if (state === null) return null;
2371
+ if (state !== "open") return state;
2372
+ if (reviewState === null) return null;
2373
+ return prStatus({
2374
+ state,
2375
+ reviewDecision: reviewflowReviewDecisions[reviewState]
2376
+ });
2377
+ };
2378
+
2320
2379
  const pullRequestParts = [
2321
2380
  "detail",
2322
2381
  "checks",
@@ -2401,6 +2460,32 @@ const pullRequestEditMovesBoard = (edit) => {
2401
2460
  }
2402
2461
  };
2403
2462
 
2463
+ const dayMs = 24 * 60 * 60 * 1e3;
2464
+ const weekdayReach = 6;
2465
+ const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
2466
+ const calendarDaysAgo = (date, now) => Math.round((startOfDay(now) - startOfDay(date)) / dayMs);
2467
+ const formatRelativeDate = (value, { now = /* @__PURE__ */ new Date(), locale } = {}) => {
2468
+ const date = typeof value === "string" ? new Date(value) : value;
2469
+ if (Number.isNaN(date.getTime())) return "unknown date";
2470
+ const time = date.toLocaleTimeString(locale, {
2471
+ hour: "2-digit",
2472
+ minute: "2-digit"
2473
+ });
2474
+ const days = calendarDaysAgo(date, now);
2475
+ if (days === 0) return `today ${time}`;
2476
+ if (days === 1) return `yesterday ${time}`;
2477
+ if (days > 1 && days <= weekdayReach) {
2478
+ const weekday = date.toLocaleDateString("en", { weekday: "long" });
2479
+ return `last ${weekday} ${time}`;
2480
+ }
2481
+ const day = date.toLocaleDateString(locale, {
2482
+ day: "numeric",
2483
+ month: "short",
2484
+ ...date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }
2485
+ });
2486
+ return `${day}, ${time}`;
2487
+ };
2488
+
2404
2489
  const partFields = {
2405
2490
  detail: (pr) => [
2406
2491
  pr.title,
@@ -3074,6 +3159,92 @@ const applyStreamEvent = (snapshot, event) => {
3074
3159
  };
3075
3160
  };
3076
3161
 
3162
+ const maxFailedCheckNames = 9;
3163
+ const bucketsSilentOnPassedChecks = /* @__PURE__ */ new Set(["changes-requested", "drafts"]);
3164
+ const reRequestedBucket = "re-requested-reviews";
3165
+ const requestingAttentionGroup = "requesting-attention";
3166
+ const counted = (count, one, many) => `${String(count)} ${count === 1 ? one : many}`;
3167
+ const people = (logins, viewer) => logins.map((login) => login === viewer ? "you" : `@${login}`).join(", ");
3168
+ const failedChecksText = ({
3169
+ checksConclusion,
3170
+ failedChecks
3171
+ }) => {
3172
+ if (failedChecks.length === 0) {
3173
+ return checksConclusion === "failed" ? "checks failed" : null;
3174
+ }
3175
+ const names = failedChecks.slice(0, maxFailedCheckNames).join(", ");
3176
+ const remaining = failedChecks.length - maxFailedCheckNames;
3177
+ const more = remaining > 0 ? ` +${String(remaining)}` : "";
3178
+ return `${failedChecks.length === 1 ? "check" : "checks"} failed: ${names}${more}`;
3179
+ };
3180
+ const reviewflowPrFailure = (pr) => {
3181
+ const parts = [
3182
+ failedChecksText(pr),
3183
+ pr.lintFailed ? "pr lint failed" : null,
3184
+ pr.mergeable === "conflicting" ? "conflicts" : null
3185
+ ].filter((part) => part !== null);
3186
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3187
+ };
3188
+ const reviewflowPrChangesRequested = ({ changesRequestedBy }, viewer) => changesRequestedBy.length === 0 ? null : `changes requested by ${people(changesRequestedBy, viewer)}`;
3189
+ const checksProgressText = ({
3190
+ checksConclusion,
3191
+ checksRunning
3192
+ }, bucketKey) => {
3193
+ if (checksRunning > 0) {
3194
+ return `${counted(checksRunning, "check", "checks")} running`;
3195
+ }
3196
+ return checksConclusion === "passed" && !bucketsSilentOnPassedChecks.has(bucketKey) ? "checks passed" : null;
3197
+ };
3198
+ const awaitedText = ({
3199
+ requestedReviewers,
3200
+ reRequestedReviewers,
3201
+ requestedTeams
3202
+ }, { bucketKey, groupKey, viewer }) => {
3203
+ const reviewers = [
3204
+ ...requestedReviewers,
3205
+ ...bucketKey === reRequestedBucket ? [] : reRequestedReviewers
3206
+ ];
3207
+ if (reviewers.length === 0 && requestedTeams.length === 0) return null;
3208
+ const verb = groupKey === requestingAttentionGroup ? "requested" : "awaiting";
3209
+ const awaited = [
3210
+ ...reviewers.length === 0 ? [] : [people(reviewers, viewer)],
3211
+ ...requestedTeams.map((team) => `#${team}`)
3212
+ ];
3213
+ return `${verb} ${awaited.join(", ")}`;
3214
+ };
3215
+ const reviewflowPrProgress = (pr, context) => {
3216
+ const parts = [
3217
+ checksProgressText(pr, context.bucketKey),
3218
+ pr.approvedBy.length === 0 ? null : `approved by ${people(pr.approvedBy, context.viewer)}`,
3219
+ pr.unresolvedThreads ? counted(pr.unresolvedThreads, "unresolved thread", "unresolved threads") : null,
3220
+ awaitedText(pr, context)
3221
+ ].filter((part) => part !== null);
3222
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3223
+ };
3224
+ const reviewflowPrOwners = ({ author, assignees }, viewer) => {
3225
+ const named = [...author === null ? [] : [author], ...assignees];
3226
+ if (named.length === 0) return null;
3227
+ if (viewer !== null && named.every((login) => login === viewer)) return null;
3228
+ const byline = author === null ? null : `by ${people([author], viewer)}`;
3229
+ if (assignees.length === 0) return byline;
3230
+ const assignment = `assigned to ${people(assignees, viewer)}`;
3231
+ if (author === null || !assignees.includes(author)) {
3232
+ return byline === null ? assignment : `${byline} \xB7 ${assignment}`;
3233
+ }
3234
+ return assignees.length === 1 ? byline : assignment;
3235
+ };
3236
+ const reviewflowPrFlowDate = ({ openedAt, approvedAt }, options) => {
3237
+ if (approvedAt !== null) {
3238
+ return `approved ${formatRelativeDate(approvedAt, options)}`;
3239
+ }
3240
+ return openedAt === null ? null : `opened ${formatRelativeDate(openedAt, options)}`;
3241
+ };
3242
+ const formatPrChanges = ({
3243
+ changedFiles,
3244
+ additions,
3245
+ deletions
3246
+ }) => `${counted(changedFiles, "file", "files")} +${String(additions)} \u2212${String(deletions)}`;
3247
+
3077
3248
  const lingerMs = 5 * 6e4;
3078
3249
  const boardReadDelayMs = 3e3;
3079
3250
  const reconnectDelayMs = 1e3;
@@ -3334,5 +3505,5 @@ const insertCheckoutEntry = (source, ownerRepo) => {
3334
3505
  return { source: text, cursor: value };
3335
3506
  };
3336
3507
 
3337
- export { accountOfOwnerRepo, applyPullRequestEdit, applyStreamEvent, applySuggestionsToText, assembleBoard, boardReadDelayMs, buildPrQuery, changedPullRequestParts, checkOutcomes, checksFromReviewflow, coAuthorTrailers, commitSuggestions, createGitHubTransport, createReviewflowClient, createReviewflowFollower, diffLines, diffPaths, diffThemeNames, diffThemeOptions, diffThemes, editPullRequest, fail, fetchCompareDiff, fetchPullRequestCandidates, fetchPullRequestHead, fetchPullRequestPart, fetchPullRequestRevisions, insertCheckoutEntry, interpretGraphql, isAttachablePath, isDiffTheme, isDiffWhitespace, isPullRequestOnBoard, isPullRequestPart, isPullRequestReactionContent, isReviewflowFailure, lingerMs, maxExtraPagesPerBucket, maxLookupTargets, maxRetryMs, parsePrQueryResponse, parsePullRequestCandidates, parsePullRequestChecks, parsePullRequestCommits, parsePullRequestDetail, parsePullRequestEdit, parsePullRequestFiles, parsePullRequestRevisions, parsePullRequestTimeline, parseSuggestions, partFingerprint, planSuggestions, prFields, pullRequestCandidatesQuery, pullRequestChecksQuery, pullRequestCommitsQuery, pullRequestDetailQuery, pullRequestEditMovesBoard, pullRequestFilesQuery, pullRequestHeadQuery, pullRequestParts, pullRequestPartsStaleAfter, pullRequestReactionContents, pullRequestRevisionsQuery, pullRequestTimelineQuery, readAccountBoards, readPrDetail, readReviewflowStream, reconnectDelayMs, responseFailure, restErrorMessage, reviewflowErrorCode, splitOwnerRepo, streamClosedWithinDays, streamIdleTimeoutMs, subjectsOf, suggestionCommitRefusal, suggestionOriginalLines, suggestionRangesOverlap, toBoardPr, toFetchedPr, withAccountBoard, withFileViewed };
3508
+ export { accountOfOwnerRepo, applyPullRequestEdit, applyStreamEvent, applySuggestionsToText, assembleBoard, boardReadDelayMs, buildPrQuery, changedPullRequestParts, checkOutcomes, checksFromReviewflow, coAuthorTrailers, commitSuggestions, createGitHubTransport, createReviewflowClient, createReviewflowFollower, diffLines, diffPaths, diffThemeNames, diffThemeOptions, diffThemes, editPullRequest, fail, fetchCompareDiff, fetchPullRequestCandidates, fetchPullRequestHead, fetchPullRequestPart, fetchPullRequestRevisions, formatPrChanges, formatRelativeDate, insertCheckoutEntry, interpretGraphql, isAttachablePath, isDiffTheme, isDiffWhitespace, isPullRequestOnBoard, isPullRequestPart, isPullRequestReactionContent, isReviewflowFailure, lingerMs, maxExtraPagesPerBucket, maxFailedCheckNames, maxLookupTargets, maxRetryMs, parsePrQueryResponse, parsePullRequestCandidates, parsePullRequestChecks, parsePullRequestCommits, parsePullRequestDetail, parsePullRequestEdit, parsePullRequestFiles, parsePullRequestRevisions, parsePullRequestTimeline, parseSuggestions, partFingerprint, planSuggestions, prFields, prHasConflicts, prNumberOf, prStatus, prStatusLabels, prStatuses, prUrl, pullRequestCandidatesQuery, pullRequestChecksQuery, pullRequestCommitsQuery, pullRequestDetailQuery, pullRequestEditMovesBoard, pullRequestFilesQuery, pullRequestHeadQuery, pullRequestParts, pullRequestPartsStaleAfter, pullRequestReactionContents, pullRequestRevisionsQuery, pullRequestTimelineQuery, readAccountBoards, readPrDetail, readReviewflowStream, reconnectDelayMs, responseFailure, restErrorMessage, reviewflowErrorCode, reviewflowPrChangesRequested, reviewflowPrFailure, reviewflowPrFlowDate, reviewflowPrOwners, reviewflowPrProgress, reviewflowPrStatus, reviewflowReviewDecisions, splitOwnerRepo, streamClosedWithinDays, streamIdleTimeoutMs, subjectsOf, suggestionCommitRefusal, suggestionOriginalLines, suggestionRangesOverlap, toBoardPr, toFetchedPr, withAccountBoard, withFileViewed };
3338
3509
  //# sourceMappingURL=index-node.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reviewflow/sdk",
3
- "version": "6.21.0",
3
+ "version": "6.23.0",
4
4
  "description": "pull request model shared by the reviewflow app server and its ui: diff and api types, pure utils, the GitHub layer behind an injected transport, and (node only) reading a diff from a local checkout (@reviewflow/sdk/git) and editing a text file in place (@reviewflow/sdk/files)",
5
5
  "keywords": [
6
6
  "code-review",
@@ -61,19 +61,6 @@
61
61
  "publishConfig": {
62
62
  "access": "public"
63
63
  },
64
- "scripts": {
65
- "build": "pnpm run clean:build && rollup --config rollup.config.mjs",
66
- "clean": "pnpm run clean:build",
67
- "clean:build": "pob-esbuild-clean-out build",
68
- "lint": "pnpm run lint:oxlint && pnpm run lint:eslint",
69
- "lint:eslint": "pnpm --dir '../..' exec eslint --quiet 'packages/sdk'",
70
- "lint:oxlint": "pnpm --dir '../..' exec oxlint --quiet 'packages/sdk'",
71
- "start": "pnpm run clean:build && rollup --config rollup.config.mjs --watch",
72
- "test": "pnpm run --dir '../..' test -- 'packages/sdk'",
73
- "tsc": "tsrs",
74
- "tsc:go": "tsc",
75
- "watch": "pnpm run clean:build && rollup --config rollup.config.mjs --watch"
76
- },
77
64
  "dependencies": {
78
65
  "oxc-parser": "0.127.0"
79
66
  },
@@ -134,5 +121,18 @@
134
121
  }
135
122
  ],
136
123
  "typescript": true
124
+ },
125
+ "scripts": {
126
+ "build": "pnpm run clean:build && rollup --config rollup.config.mjs",
127
+ "clean": "pnpm run clean:build",
128
+ "clean:build": "pob-esbuild-clean-out build",
129
+ "lint": "pnpm run lint:oxlint && pnpm run lint:eslint",
130
+ "lint:eslint": "pnpm --dir '../..' exec eslint --quiet 'packages/sdk'",
131
+ "lint:oxlint": "pnpm --dir '../..' exec oxlint --quiet 'packages/sdk'",
132
+ "start": "pnpm run clean:build && rollup --config rollup.config.mjs --watch",
133
+ "test": "pnpm run --dir '../..' test -- 'packages/sdk'",
134
+ "tsc": "tsrs",
135
+ "tsc:go": "tsc",
136
+ "watch": "pnpm run clean:build && rollup --config rollup.config.mjs --watch"
137
137
  }
138
- }
138
+ }
package/src/index.ts CHANGED
@@ -20,15 +20,18 @@ export type * from "./api/pullRequest.ts";
20
20
  export type * from "./types/entities.ts";
21
21
  export type * from "./types/ids.ts";
22
22
  export type * from "./types/reviewflow.ts";
23
+ export * from "./utils/prStatus.ts";
23
24
  export * from "./utils/pullRequestEdit.ts";
24
25
  export * from "./utils/pullRequestPart.ts";
25
26
  export * from "./utils/pullRequestReaction.ts";
26
27
  export * from "./utils/pullRequestSuggestion.ts";
28
+ export * from "./utils/relativeDate.ts";
27
29
  export * from "./reviewflow/changedParts.ts";
28
30
  export * from "./reviewflow/checksFromReviewflow.ts";
29
31
  export * from "./reviewflow/client.ts";
30
32
  export * from "./reviewflow/stream.ts";
31
33
  export * from "./reviewflow/board.ts";
34
+ export * from "./reviewflow/boardFacts.ts";
32
35
  export * from "./reviewflow/follow.ts";
33
36
  export type * from "./reviewflow/status.ts";
34
37
  export { insertCheckoutEntry } from "./config/insertCheckoutEntry.ts";
@@ -0,0 +1,184 @@
1
+ import type { ReviewflowPrChanges } from "../types/reviewflow.ts";
2
+ import type { FormatRelativeDateOptions } from "../utils/relativeDate.ts";
3
+ import { formatRelativeDate } from "../utils/relativeDate.ts";
4
+ import type { ReviewflowBoardPr } from "./board.ts";
5
+
6
+ /**
7
+ * What reviewflow's own board row says about a pull request, in its wording so
8
+ * the two boards read the same. The status line comes in three parts by how
9
+ * loudly each is shown: what blocks the merge, changes requested, the rest.
10
+ */
11
+
12
+ export const maxFailedCheckNames = 9;
13
+
14
+ /** Where a row is shown, which decides what it leaves to its bucket's title. */
15
+ export interface ReviewflowRowContext {
16
+ bucketKey: string;
17
+ groupKey: string;
18
+ /** the token's login, named "you" */
19
+ viewer: string | null;
20
+ }
21
+
22
+ /** the buckets whose title already says whether the checks pass */
23
+ const bucketsSilentOnPassedChecks = new Set(["changes-requested", "drafts"]);
24
+ /** the bucket whose title already says every reviewer was asked again */
25
+ const reRequestedBucket = "re-requested-reviews";
26
+ /** someone else's pull request requests a review, the operator's awaits one */
27
+ const requestingAttentionGroup = "requesting-attention";
28
+
29
+ const counted = (count: number, one: string, many: string): string =>
30
+ `${String(count)} ${count === 1 ? one : many}`;
31
+
32
+ const people = (logins: string[], viewer: string | null): string =>
33
+ logins.map((login) => (login === viewer ? "you" : `@${login}`)).join(", ");
34
+
35
+ const failedChecksText = ({
36
+ checksConclusion,
37
+ failedChecks,
38
+ }: Pick<ReviewflowBoardPr, "checksConclusion" | "failedChecks">):
39
+ | string
40
+ | null => {
41
+ if (failedChecks.length === 0) {
42
+ return checksConclusion === "failed" ? "checks failed" : null;
43
+ }
44
+ const names = failedChecks.slice(0, maxFailedCheckNames).join(", ");
45
+ const remaining = failedChecks.length - maxFailedCheckNames;
46
+ const more = remaining > 0 ? ` +${String(remaining)}` : "";
47
+ return `${failedChecks.length === 1 ? "check" : "checks"} failed: ${names}${more}`;
48
+ };
49
+
50
+ /**
51
+ * Failing checks (after reviewflow's allowed-to-fail rules, by name), a failed
52
+ * pr lint and a merge conflict; null when nothing blocks.
53
+ */
54
+ export const reviewflowPrFailure = (
55
+ pr: Pick<
56
+ ReviewflowBoardPr,
57
+ "checksConclusion" | "failedChecks" | "lintFailed" | "mergeable"
58
+ >,
59
+ ): string | null => {
60
+ const parts = [
61
+ failedChecksText(pr),
62
+ pr.lintFailed ? "pr lint failed" : null,
63
+ pr.mergeable === "conflicting" ? "conflicts" : null,
64
+ ].filter((part) => part !== null);
65
+ return parts.length === 0 ? null : parts.join(" · ");
66
+ };
67
+
68
+ export const reviewflowPrChangesRequested = (
69
+ { changesRequestedBy }: Pick<ReviewflowBoardPr, "changesRequestedBy">,
70
+ viewer: string | null,
71
+ ): string | null =>
72
+ changesRequestedBy.length === 0
73
+ ? null
74
+ : `changes requested by ${people(changesRequestedBy, viewer)}`;
75
+
76
+ const checksProgressText = (
77
+ {
78
+ checksConclusion,
79
+ checksRunning,
80
+ }: Pick<ReviewflowBoardPr, "checksConclusion" | "checksRunning">,
81
+ bucketKey: string,
82
+ ): string | null => {
83
+ if (checksRunning > 0) {
84
+ return `${counted(checksRunning, "check", "checks")} running`;
85
+ }
86
+ return checksConclusion === "passed" &&
87
+ !bucketsSilentOnPassedChecks.has(bucketKey)
88
+ ? "checks passed"
89
+ : null;
90
+ };
91
+
92
+ const awaitedText = (
93
+ {
94
+ requestedReviewers,
95
+ reRequestedReviewers,
96
+ requestedTeams,
97
+ }: Pick<
98
+ ReviewflowBoardPr,
99
+ "requestedReviewers" | "requestedTeams" | "reRequestedReviewers"
100
+ >,
101
+ { bucketKey, groupKey, viewer }: ReviewflowRowContext,
102
+ ): string | null => {
103
+ const reviewers = [
104
+ ...requestedReviewers,
105
+ ...(bucketKey === reRequestedBucket ? [] : reRequestedReviewers),
106
+ ];
107
+ if (reviewers.length === 0 && requestedTeams.length === 0) return null;
108
+ const verb = groupKey === requestingAttentionGroup ? "requested" : "awaiting";
109
+ const awaited = [
110
+ ...(reviewers.length === 0 ? [] : [people(reviewers, viewer)]),
111
+ ...requestedTeams.map((team) => `#${team}`),
112
+ ];
113
+ return `${verb} ${awaited.join(", ")}`;
114
+ };
115
+
116
+ /** running or passed checks, approvals, open threads, awaited reviewers */
117
+ export const reviewflowPrProgress = (
118
+ pr: Pick<
119
+ ReviewflowBoardPr,
120
+ | "approvedBy"
121
+ | "checksConclusion"
122
+ | "checksRunning"
123
+ | "requestedReviewers"
124
+ | "requestedTeams"
125
+ | "reRequestedReviewers"
126
+ | "unresolvedThreads"
127
+ >,
128
+ context: ReviewflowRowContext,
129
+ ): string | null => {
130
+ const parts = [
131
+ checksProgressText(pr, context.bucketKey),
132
+ pr.approvedBy.length === 0
133
+ ? null
134
+ : `approved by ${people(pr.approvedBy, context.viewer)}`,
135
+ pr.unresolvedThreads
136
+ ? counted(pr.unresolvedThreads, "unresolved thread", "unresolved threads")
137
+ : null,
138
+ awaitedText(pr, context),
139
+ ].filter((part) => part !== null);
140
+ return parts.length === 0 ? null : parts.join(" · ");
141
+ };
142
+
143
+ /**
144
+ * "by @author", "assigned to @a", or both. An author who is their own only
145
+ * assignee is a byline, not an assignment; a pull request whose only person is
146
+ * the viewer names nobody.
147
+ */
148
+ export const reviewflowPrOwners = (
149
+ { author, assignees }: Pick<ReviewflowBoardPr, "assignees" | "author">,
150
+ viewer: string | null,
151
+ ): string | null => {
152
+ const named = [...(author === null ? [] : [author]), ...assignees];
153
+ if (named.length === 0) return null;
154
+ if (viewer !== null && named.every((login) => login === viewer)) return null;
155
+
156
+ const byline = author === null ? null : `by ${people([author], viewer)}`;
157
+ if (assignees.length === 0) return byline;
158
+ const assignment = `assigned to ${people(assignees, viewer)}`;
159
+
160
+ if (author === null || !assignees.includes(author)) {
161
+ return byline === null ? assignment : `${byline} · ${assignment}`;
162
+ }
163
+ // naming every assignee already names the author among them
164
+ return assignees.length === 1 ? byline : assignment;
165
+ };
166
+
167
+ export const reviewflowPrFlowDate = (
168
+ { openedAt, approvedAt }: Pick<ReviewflowBoardPr, "approvedAt" | "openedAt">,
169
+ options?: FormatRelativeDateOptions,
170
+ ): string | null => {
171
+ if (approvedAt !== null) {
172
+ return `approved ${formatRelativeDate(approvedAt, options)}`;
173
+ }
174
+ return openedAt === null
175
+ ? null
176
+ : `opened ${formatRelativeDate(openedAt, options)}`;
177
+ };
178
+
179
+ export const formatPrChanges = ({
180
+ changedFiles,
181
+ additions,
182
+ deletions,
183
+ }: ReviewflowPrChanges): string =>
184
+ `${counted(changedFiles, "file", "files")} +${String(additions)} −${String(deletions)}`;
@@ -0,0 +1,152 @@
1
+ import type { ReviewflowBoardPr } from "../reviewflow/board.ts";
2
+ import type {
3
+ PrMergeable,
4
+ PrReviewDecision,
5
+ PrState,
6
+ } from "../types/entities.ts";
7
+ import type { ReviewflowReviewState } from "../types/reviewflow.ts";
8
+
9
+ /**
10
+ * What the operator is told about a pull request, in one word or two.
11
+ *
12
+ * `pr.state` and `pr.reviewDecision` are two columns because GitHub reports two
13
+ * fields, but they are never two answers: an open PR is only ever interesting
14
+ * for where its review stands, and "open" on its own says nothing the branch did
15
+ * not already say. So the two collapse into one status, and every surface
16
+ * reads it from here rather than
17
+ * formatting the columns itself. They had drifted into three different renderings
18
+ * of the same row.
19
+ *
20
+ * `noReviewers` and `waitingReview` are a real distinction, not a null check:
21
+ * GitHub leaves `reviewDecision` null when nothing is required and nobody was
22
+ * asked, and answers `REVIEW_REQUIRED` when a review is required and missing.
23
+ * The first is the operator's own omission — nobody is coming — and the second
24
+ * is somebody else's turn.
25
+ */
26
+ export const prStatuses = [
27
+ "approved",
28
+ "changesRequested",
29
+ "closed",
30
+ "draft",
31
+ "merged",
32
+ "noReviewers",
33
+ "waitingReview",
34
+ ] as const;
35
+
36
+ export type PrStatus = (typeof prStatuses)[number];
37
+
38
+ const reviewStatuses: Record<
39
+ NonNullable<PrReviewDecision>,
40
+ Extract<PrStatus, "approved" | "changesRequested" | "waitingReview">
41
+ > = {
42
+ approved: "approved",
43
+ changesRequested: "changesRequested",
44
+ reviewRequired: "waitingReview",
45
+ };
46
+
47
+ /**
48
+ * A closed or merged PR keeps its own status whatever the last review said: an
49
+ * approved PR that was closed is closed, and saying "approved" would describe a
50
+ * decision nobody is going to act on.
51
+ */
52
+ export const prStatus = ({
53
+ state,
54
+ reviewDecision,
55
+ }: {
56
+ state: PrState;
57
+ reviewDecision: PrReviewDecision;
58
+ }): PrStatus => {
59
+ if (state !== "open") return state;
60
+ return reviewDecision === null
61
+ ? "noReviewers"
62
+ : reviewStatuses[reviewDecision];
63
+ };
64
+
65
+ export const prStatusLabels: Record<PrStatus, string> = {
66
+ approved: "approved",
67
+ changesRequested: "changes requested",
68
+ closed: "closed",
69
+ draft: "draft",
70
+ merged: "merged",
71
+ noReviewers: "no reviewers",
72
+ waitingReview: "waiting review",
73
+ };
74
+
75
+ /**
76
+ * Only while it can still be merged: GitHub keeps answering for a closed one,
77
+ * and a conflict nobody will resolve is not something to look at.
78
+ */
79
+ export const prHasConflicts = ({
80
+ state,
81
+ mergeable,
82
+ }: {
83
+ state: PrState;
84
+ mergeable: PrMergeable;
85
+ }): boolean =>
86
+ (state === "open" || state === "draft") && mergeable === "conflicting";
87
+
88
+ /**
89
+ * Built rather than stored: the number and `owner/repo` are kept apart,
90
+ * and a url column would be a third copy of both that goes stale when either
91
+ * moves. GitHub redirects `/pull/<n>` to the right kind of page, so nothing here
92
+ * has to know whether the number belongs to a PR or an issue.
93
+ */
94
+ export const prUrl = (ownerRepo: string, number: number): string =>
95
+ `https://github.com/${ownerRepo}/pull/${String(number)}`;
96
+
97
+ const prNumberPattern = /^#?(\d+)$/;
98
+ const prUrlPattern =
99
+ /^https?:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)(?:[/?#]\S*)?$/i;
100
+
101
+ /**
102
+ * A pull request of `ownerRepo` as the operator names one — `123`, `#123` or
103
+ * its GitHub url, a tab of it included — or why the text names none. A url of
104
+ * another repository is refused rather than reduced to its number, which would
105
+ * name a different pull request.
106
+ */
107
+ export const prNumberOf = (
108
+ input: string,
109
+ ownerRepo: string,
110
+ ): number | string => {
111
+ const text = input.trim();
112
+ const url = prUrlPattern.exec(text);
113
+ const [, digits] = url === null ? (prNumberPattern.exec(text) ?? []) : [];
114
+ if (url !== null && url[1]?.toLowerCase() !== ownerRepo.toLowerCase()) {
115
+ return `that pull request is not in ${ownerRepo}`;
116
+ }
117
+ const number = Number(url?.[2] ?? digits);
118
+ if (!Number.isInteger(number) || number < 1) {
119
+ return "a pull request number or its GitHub link is expected";
120
+ }
121
+ return number;
122
+ };
123
+
124
+ /**
125
+ * reviewflow's review state in GitHub's vocabulary, which `pr` stores. `none` is
126
+ * what GitHub cannot say on a repository that requires a review: it answers
127
+ * `REVIEW_REQUIRED` whether or not anyone was asked, where reviewflow tells the
128
+ * two apart.
129
+ */
130
+ export const reviewflowReviewDecisions: Record<
131
+ ReviewflowReviewState,
132
+ PrReviewDecision
133
+ > = {
134
+ approved: "approved",
135
+ changesRequested: "changesRequested",
136
+ requested: "reviewRequired",
137
+ none: null,
138
+ };
139
+
140
+ /** null when reviewflow does not know enough to say */
141
+ export const reviewflowPrStatus = ({
142
+ state,
143
+ reviewState,
144
+ }: Pick<ReviewflowBoardPr, "reviewState" | "state">): PrStatus | null => {
145
+ if (state === null) return null;
146
+ if (state !== "open") return state;
147
+ if (reviewState === null) return null;
148
+ return prStatus({
149
+ state,
150
+ reviewDecision: reviewflowReviewDecisions[reviewState],
151
+ });
152
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A past date the way the operator says it: "today 14:32", "yesterday 09:10",
3
+ * "last Monday 16:05", then "12 Oct, 14:32" — and the year once it is not this
4
+ * one. The boards read dates to answer "is this from this morning or last
5
+ * week", which a full timestamp makes them work out.
6
+ *
7
+ * Calendar days in the machine's own time zone, not 24-hour spans: 23:50
8
+ * yesterday is "yesterday" at 00:10. The time and the month follow the
9
+ * locale; the words are the app's, which is in English.
10
+ */
11
+
12
+ const dayMs = 24 * 60 * 60 * 1000;
13
+
14
+ /** how many days back the weekday alone still names the day without doubt */
15
+ const weekdayReach = 6;
16
+
17
+ const startOfDay = (date: Date): number =>
18
+ new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
19
+
20
+ /** Whole calendar days from `date` to `now`; negative for a future date. */
21
+ const calendarDaysAgo = (date: Date, now: Date): number =>
22
+ Math.round((startOfDay(now) - startOfDay(date)) / dayMs);
23
+
24
+ export interface FormatRelativeDateOptions {
25
+ now?: Date;
26
+ /** a BCP 47 tag; the runtime's own when absent */
27
+ locale?: string;
28
+ }
29
+
30
+ export const formatRelativeDate = (
31
+ value: Date | string,
32
+ { now = new Date(), locale }: FormatRelativeDateOptions = {},
33
+ ): string => {
34
+ const date = typeof value === "string" ? new Date(value) : value;
35
+ if (Number.isNaN(date.getTime())) return "unknown date";
36
+
37
+ const time = date.toLocaleTimeString(locale, {
38
+ hour: "2-digit",
39
+ minute: "2-digit",
40
+ });
41
+ const days = calendarDaysAgo(date, now);
42
+
43
+ if (days === 0) return `today ${time}`;
44
+ if (days === 1) return `yesterday ${time}`;
45
+ if (days > 1 && days <= weekdayReach) {
46
+ const weekday = date.toLocaleDateString("en", { weekday: "long" });
47
+ return `last ${weekday} ${time}`;
48
+ }
49
+
50
+ const day = date.toLocaleDateString(locale, {
51
+ day: "numeric",
52
+ month: "short",
53
+ ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }),
54
+ });
55
+ return `${day}, ${time}`;
56
+ };