@reviewflow/sdk 6.22.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.
- package/build/index-browser.es.js +182 -1
- package/build/index-node.mjs +182 -1
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/reviewflow/boardFacts.ts +184 -0
- package/src/utils/prStatus.ts +152 -0
- package/src/utils/relativeDate.ts +56 -0
|
@@ -2307,6 +2307,75 @@ const createGitHubTransport = (send) => ({
|
|
|
2307
2307
|
}
|
|
2308
2308
|
});
|
|
2309
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
|
+
|
|
2310
2379
|
const pullRequestParts = [
|
|
2311
2380
|
"detail",
|
|
2312
2381
|
"checks",
|
|
@@ -2391,6 +2460,32 @@ const pullRequestEditMovesBoard = (edit) => {
|
|
|
2391
2460
|
}
|
|
2392
2461
|
};
|
|
2393
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
|
+
|
|
2394
2489
|
const partFields = {
|
|
2395
2490
|
detail: (pr) => [
|
|
2396
2491
|
pr.title,
|
|
@@ -3064,6 +3159,92 @@ const applyStreamEvent = (snapshot, event) => {
|
|
|
3064
3159
|
};
|
|
3065
3160
|
};
|
|
3066
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
|
+
|
|
3067
3248
|
const lingerMs = 5 * 6e4;
|
|
3068
3249
|
const boardReadDelayMs = 3e3;
|
|
3069
3250
|
const reconnectDelayMs = 1e3;
|
|
@@ -3324,5 +3505,5 @@ const insertCheckoutEntry = (source, ownerRepo) => {
|
|
|
3324
3505
|
return { source: text, cursor: value };
|
|
3325
3506
|
};
|
|
3326
3507
|
|
|
3327
|
-
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 };
|
|
3328
3509
|
//# sourceMappingURL=index-browser.es.js.map
|
package/build/index-node.mjs
CHANGED
|
@@ -2307,6 +2307,75 @@ const createGitHubTransport = (send) => ({
|
|
|
2307
2307
|
}
|
|
2308
2308
|
});
|
|
2309
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
|
+
|
|
2310
2379
|
const pullRequestParts = [
|
|
2311
2380
|
"detail",
|
|
2312
2381
|
"checks",
|
|
@@ -2391,6 +2460,32 @@ const pullRequestEditMovesBoard = (edit) => {
|
|
|
2391
2460
|
}
|
|
2392
2461
|
};
|
|
2393
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
|
+
|
|
2394
2489
|
const partFields = {
|
|
2395
2490
|
detail: (pr) => [
|
|
2396
2491
|
pr.title,
|
|
@@ -3064,6 +3159,92 @@ const applyStreamEvent = (snapshot, event) => {
|
|
|
3064
3159
|
};
|
|
3065
3160
|
};
|
|
3066
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
|
+
|
|
3067
3248
|
const lingerMs = 5 * 6e4;
|
|
3068
3249
|
const boardReadDelayMs = 3e3;
|
|
3069
3250
|
const reconnectDelayMs = 1e3;
|
|
@@ -3324,5 +3505,5 @@ const insertCheckoutEntry = (source, ownerRepo) => {
|
|
|
3324
3505
|
return { source: text, cursor: value };
|
|
3325
3506
|
};
|
|
3326
3507
|
|
|
3327
|
-
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 };
|
|
3328
3509
|
//# sourceMappingURL=index-node.mjs.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reviewflow/sdk",
|
|
3
|
-
"version": "6.
|
|
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",
|
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
|
+
};
|