prreviewbuddy 0.52.2 → 0.55.1
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/CHANGELOG.md +99 -0
- package/dist/{delete_review-BiV1aBpx.js → delete_review-B2bqhgtx.js} +557 -115
- package/dist/main.js +1 -1
- package/dist/server.js +567 -66
- package/package.json +1 -1
- package/static/chunk-BeqgAIrQ.js +4 -4
- package/static/chunk-BpiqRF0Y.js +1 -0
- package/static/chunk-RriGSD6H.js +1 -0
- package/static/notifications.js +1 -1
- package/static/reviews.js +1 -1
- package/static/shell.css +147 -22
- package/static/sidebar.css +137 -0
- package/static/workspace.js +44 -36
- package/static/chunk-CofqJ-Qn.js +0 -1
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as relativeTime$1, $n as AGENT_IDS, $t as kissDecisionRefusal, A as createPrState, An as readQueue, At as checkCodeFreshness, B as toneOf, Bn as WITHHELD_REASON, C as readEvents, Cn as offersLifecycle, Ct as statedWorkspacePort, D as announceJob, Dn as queueAddMany, Dt as forgeFor, E as telemetryUploadConsent, En as queueAdd, F as markAllRead, Fn as questionsOutstanding, Ft as endedAt, G as isLongDescription, Gn as git, H as withUsageRecorded, Ht as phasesFor, I as markRead, In as reviewerDispositions, It as fail, J as ReviewBeingDeletedError, Jn as clearAgentPreference, Jt as decideKissRecommendation, K as asString, L as pruneNotifications, Ln as processDiscussion, Lt as isTerminal, M as eventName, Mn as isIssueOutstanding, Mt as followedBranch, N as headline, Nn as isQuestionOutstanding, Nt as reviewedShaOf, O as branchOf, On as queueRemove, Ot as remoteHost, P as listNotifications, Pn as issuesOutstanding, Pt as allJobs, Qn as STORE_ROOT, Qt as indexReviewGroups, R as readNotification, Rn as recommendationsOf, Rt as loadJob, Sn as lifecyclePill, St as resolveWorkspacePort, T as recordTelemetryUploadConsent, Tn as sectionOf, Tt as claimRequestUrl, U as EXPLAIN_SIMPLY_PROMPT, Ut as progressSteps, V as unreadCount$1, Vn as kissOutcome, Vt as isLiveJob, W as hasAuthorContent, Wn as isCommitReview, Wt as purposeOf, X as readMarker, Xn as writeAgentPreference, Xt as groupByLineage, Y as MANAGED_ROOT, Yn as readAgentPreference, Yt as followedRefName, Z as removeWorktree, Zt as indexKeptIds, _ as agentEnvOf, _n as LIFECYCLES, _t as feedbackUrl, a as wasBlocked, ar as AgentUnavailableError, b as reanalyseReview, bn as lifecycleAction, bt as MIN_WORKSPACE_PORT, c as RefreshUnavailableError, cn as presentedKissRunOf, ct as readServerRecord, d as refreshPrContext, dn as reviewedCommit, dt as spawnServer, en as kissDecisionsOf, et as activateQueued, f as isUnchanged, gn as workspaceStamps, h as liveUpdateFor, hn as workspaceRevision, ht as BUILD_VERSION, i as liveJobsFor, in as loadWorkspace, ir as AgentCancelledError, it as readIndexToken, j as createPrUpdate, jn as doneVerb, jt as checkReview, k as subjectOf, kn as queueStamp, kt as fillFileUrlTemplate, l as mergeUpdateInto, ln as previousKissResult, m as UpdateAlreadyRunningError, mn as touchWorkspace, mt as writeServerRecord, n as deleteReview, nr as agentFor, o as discardJob, on as mutateWorkspace, ot as clearServerRecord, p as askCheckout, q as extractJsonText, qn as resolveModel, qt as isClaimed, r as lineageIds, rn as lineagePosition, rt as settleQueue, s as FETCH_TIMEOUT_MS, t as deleteLineage, tn as latestKissRun, tt as compactQueue, u as updateReview, un as recentWorkspaces, ut as serverEntryExists, vn as activityLabel, vt as DEFAULT_WORKSPACE_PORT, w as record, wn as parseLifecycle, wt as writePortPreference, x as runJob, xn as lifecycleChip, xt as parseWorkspacePort, y as startKissJob, yn as isLifecycle, yt as MAX_WORKSPACE_PORT, z as removeNotification, zn as describeAuthorship, zt as saveJob } from "./delete_review-B2bqhgtx.js";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -186,6 +186,7 @@ function composeReview(session) {
|
|
|
186
186
|
return {
|
|
187
187
|
schemaVersion: 1,
|
|
188
188
|
summary: primary.summary,
|
|
189
|
+
...primary.motivation ? { motivation: primary.motivation } : {},
|
|
189
190
|
recommendation: primary.recommendation,
|
|
190
191
|
nextSteps: primary.nextSteps,
|
|
191
192
|
overallRisk,
|
|
@@ -839,13 +840,28 @@ var FINDING_COPY_CLASS = "pr-helper-finding-copy";
|
|
|
839
840
|
* a chat reply's "Copy message".
|
|
840
841
|
*/
|
|
841
842
|
function renderFindingCopySource(source, kind) {
|
|
842
|
-
const name = kind === "issue" ? "Copy finding" : "Copy question";
|
|
843
|
+
const name = kind === "issue" ? "Copy finding" : kind === "question" ? "Copy question" : "Copy recommendation";
|
|
843
844
|
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"`, {
|
|
844
845
|
label: "",
|
|
845
846
|
aria: name,
|
|
846
847
|
tip: name
|
|
847
848
|
});
|
|
848
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* The same control with its word showing, for a footer of worded actions.
|
|
852
|
+
*
|
|
853
|
+
* A KISS card's Copy draft comment sits among Plan to simplify and Leave it, where an icon alone
|
|
854
|
+
* would read as decoration. `extra` is for attributes the caller's own client needs, such as the
|
|
855
|
+
* handle Copy recommendations is found by; the source may be updated after render, since the
|
|
856
|
+
* listener reads it at the moment of the click.
|
|
857
|
+
*/
|
|
858
|
+
function renderLabelledCopySource(source, label, extra = "") {
|
|
859
|
+
return button(`${MESSAGE_SOURCE_ATTR}="${escapeHtml$1(source)}"${extra ? ` ${extra}` : ""}`, {
|
|
860
|
+
label,
|
|
861
|
+
aria: label,
|
|
862
|
+
tip: label
|
|
863
|
+
});
|
|
864
|
+
}
|
|
849
865
|
/** Where a finding's button keeps its resting word and name. Absent on a chat reply's. */
|
|
850
866
|
var IDLE_LABEL_ATTR = "data-copy-idle-label";
|
|
851
867
|
var IDLE_ARIA_ATTR = "data-copy-idle-aria";
|
|
@@ -1097,6 +1113,32 @@ function renderInline(value) {
|
|
|
1097
1113
|
}).replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => SAFE_HREF.test(href) ? `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>` : match).replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>").replace(/__([^_\n]+)__/g, "<strong>$1</strong>").replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>").replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, "$1<em>$2</em>").replace(INLINE_TOKEN, (_match, index) => `<code>${codeSpans[Number(index)]}</code>`);
|
|
1098
1114
|
}
|
|
1099
1115
|
//#endregion
|
|
1116
|
+
//#region ../../packages/review-harness/src/domain/ui/components/intent_note.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* The Overview's unclear motive, kept in step with its Intent question.
|
|
1119
|
+
*
|
|
1120
|
+
* Its own module for the same reason the Author's context toggle is: the workspace marks a question
|
|
1121
|
+
* answered in place, without re-rendering the Overview, and its client should not have to bundle
|
|
1122
|
+
* the tab renderer to keep the two in agreement. The markup is written by `renderWhyThisChange`.
|
|
1123
|
+
*
|
|
1124
|
+
* Marking a question answered records that it was answered, never what the answer was, so nothing
|
|
1125
|
+
* here touches the stored motivation. It only says the analysis is older than the answer.
|
|
1126
|
+
*/
|
|
1127
|
+
var INTENT_NOTE_COPY = "This question is marked answered. If the answer was added to the PR description or conversation, run the review again to incorporate it.";
|
|
1128
|
+
var RUN_REVIEW_AGAIN = "Run review again";
|
|
1129
|
+
//#endregion
|
|
1130
|
+
//#region ../../packages/review-harness/src/domain/ui/components/author_context_toggle.ts
|
|
1131
|
+
/**
|
|
1132
|
+
* The Author's context preview, opened and closed.
|
|
1133
|
+
*
|
|
1134
|
+
* Its own module because both hosts call it from their own click handling, and the workspace
|
|
1135
|
+
* client should not have to bundle the whole tab renderer to do it. The markup it acts on is
|
|
1136
|
+
* written by `renderAuthorContext` in review_tabs.ts, from the same constants.
|
|
1137
|
+
*/
|
|
1138
|
+
var AUTHOR_CONTEXT_COLLAPSED_CLASS = "pr-helper-author-context-collapsed";
|
|
1139
|
+
var SHOW_FULL_DESCRIPTION = "Show full description";
|
|
1140
|
+
var AUTHOR_CONTEXT_SECTION_ID = "pr-helper-author-context";
|
|
1141
|
+
//#endregion
|
|
1100
1142
|
//#region ../../packages/review-harness/src/domain/utils/model_label.ts
|
|
1101
1143
|
var KNOWN_BRANDS = {
|
|
1102
1144
|
gpt: "GPT",
|
|
@@ -1735,6 +1777,109 @@ function renderOverviewActions(session, options) {
|
|
|
1735
1777
|
if (!options?.diagrams) return explain;
|
|
1736
1778
|
return `<div class="pr-helper-overview-actions">${explain}<button type="button" class="pr-helper-map-button" data-view-nav="map">Diagrams</button></div>`;
|
|
1737
1779
|
}
|
|
1780
|
+
/**
|
|
1781
|
+
* The pull request's description, as the author wrote it, ahead of anything we say about the change.
|
|
1782
|
+
*
|
|
1783
|
+
* Read from the analysis's own snapshot, never from the live request, so an older analysis shows
|
|
1784
|
+
* the description it was made against. Nothing at all when there is none or it is an unfilled
|
|
1785
|
+
* template: an empty "Author's context" would read as the author having said nothing, when on a
|
|
1786
|
+
* local branch there was never anyone to ask.
|
|
1787
|
+
*
|
|
1788
|
+
* Long descriptions are clamped rather than cut. The whole text is in the page, checklists and
|
|
1789
|
+
* testing notes included, and the button only changes how much of it shows.
|
|
1790
|
+
*/
|
|
1791
|
+
function renderAuthorContext(session) {
|
|
1792
|
+
const description = session.metadata.authorDescription;
|
|
1793
|
+
if (!hasAuthorContent(description)) return "";
|
|
1794
|
+
const id = "pr-helper-author-context-body";
|
|
1795
|
+
const long = isLongDescription(description);
|
|
1796
|
+
const toggle = long ? `<button type="button" class="pr-helper-author-context-toggle" aria-expanded="false" aria-controls="${id}" data-author-context-toggle>${SHOW_FULL_DESCRIPTION}</button>` : "";
|
|
1797
|
+
return `
|
|
1798
|
+
<section class="pr-helper-author-context" id="${AUTHOR_CONTEXT_SECTION_ID}" aria-labelledby="pr-helper-author-context-heading">
|
|
1799
|
+
<h4 class="pr-helper-overview-heading" id="pr-helper-author-context-heading" tabindex="-1">Author's context</h4>
|
|
1800
|
+
<div class="pr-helper-author-context-body pr-helper-markdown${long ? ` ${AUTHOR_CONTEXT_COLLAPSED_CLASS}` : ""}" id="${id}">${renderMarkdown(description)}</div>
|
|
1801
|
+
${toggle}
|
|
1802
|
+
</section>
|
|
1803
|
+
`;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* An evidence path, presented as what the host could establish about it. A changed file opens in
|
|
1807
|
+
* the diff and a real unchanged one in the source view; a path nobody could check is shown as text,
|
|
1808
|
+
* with no `data-file`, so nothing invites the reader to trust it as a link.
|
|
1809
|
+
*/
|
|
1810
|
+
function renderEvidenceFile(item) {
|
|
1811
|
+
if (!item.file) return "";
|
|
1812
|
+
if (item.fileState === "changed") return renderFileLocation(item.file, "pr-helper-why-evidence-file");
|
|
1813
|
+
if (item.fileState === "unchanged") return renderRelatedFiles([item.file]);
|
|
1814
|
+
return `<span class="pr-helper-why-evidence-unverified"><code>${escapeHtml$1(item.file)}</code> · unverified path</span>`;
|
|
1815
|
+
}
|
|
1816
|
+
var MOTIVATION_LABELS = {
|
|
1817
|
+
stated: "Stated by the author",
|
|
1818
|
+
inferred: "Likely motivation",
|
|
1819
|
+
unclear: "Motivation unclear"
|
|
1820
|
+
};
|
|
1821
|
+
var EVIDENCE_LABELS = {
|
|
1822
|
+
test: "Test",
|
|
1823
|
+
behaviour: "Behaviour",
|
|
1824
|
+
issue: "Issue",
|
|
1825
|
+
commit: "Commit",
|
|
1826
|
+
docs: "Docs",
|
|
1827
|
+
code: "Code"
|
|
1828
|
+
};
|
|
1829
|
+
/**
|
|
1830
|
+
* Why the change exists, and how the review knows.
|
|
1831
|
+
*
|
|
1832
|
+
* Every status shows what supports it: a stated motive its quote, an inferred one its evidence, an
|
|
1833
|
+
* unclear one the question that would settle it. `verifyMotivation` guarantees each is there, so a
|
|
1834
|
+
* status rendered without its support would be a stored result this version did not write.
|
|
1835
|
+
*
|
|
1836
|
+
* Nothing for a result with no motivation, which covers every review made before the field and
|
|
1837
|
+
* every review where the motive would not change how it should be read.
|
|
1838
|
+
*/
|
|
1839
|
+
function renderWhyThisChange(motivation, session, questions) {
|
|
1840
|
+
if (!motivation) return "";
|
|
1841
|
+
if (motivation.status === "unclear") return renderUnclearMotive(motivation, session, questions);
|
|
1842
|
+
let support = "";
|
|
1843
|
+
if (motivation.status === "stated" && motivation.sourceQuote) {
|
|
1844
|
+
const source = hasAuthorContent(session.metadata.authorDescription) ? `<button type="button" class="pr-helper-why-link" data-author-context-reveal>From Author's context</button>` : "";
|
|
1845
|
+
support = `<blockquote class="pr-helper-why-quote">${renderInlineText(motivation.sourceQuote)}</blockquote>${source}`;
|
|
1846
|
+
} else if (motivation.status === "inferred" && motivation.evidence?.length) support = `<ul class="pr-helper-why-evidence">${motivation.evidence.map((item) => `<li><span class="pr-helper-why-evidence-kind">${EVIDENCE_LABELS[item.kind]}</span> ${renderInlineText(item.detail)}${renderEvidenceFile(item)}</li>`).join("")}</ul>`;
|
|
1847
|
+
return `
|
|
1848
|
+
<section class="pr-helper-why" aria-labelledby="pr-helper-why-heading">
|
|
1849
|
+
<h4 class="pr-helper-overview-heading" id="pr-helper-why-heading">Why this change?</h4>
|
|
1850
|
+
<p class="pr-helper-why-status pr-helper-why-status-${motivation.status}">${MOTIVATION_LABELS[motivation.status]}</p>
|
|
1851
|
+
<p class="pr-helper-why-summary">${renderInlineText(motivation.summary)}</p>
|
|
1852
|
+
${support}
|
|
1853
|
+
</section>
|
|
1854
|
+
`;
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* An unclear motive, and what became of its question.
|
|
1858
|
+
*
|
|
1859
|
+
* Once the question is answered the stored motivation is still the analysis's, so it is shown as
|
|
1860
|
+
* that: unclear when analysed. Only a new analysis that reads the answer may replace it, and the
|
|
1861
|
+
* button runs one. Both halves are rendered and one is hidden, so the workspace can switch them
|
|
1862
|
+
* when a question is marked answered in place (`syncIntentNote`).
|
|
1863
|
+
*/
|
|
1864
|
+
function renderUnclearMotive(motivation, session, questions) {
|
|
1865
|
+
const intent = questions.find((q) => q.category === "intent");
|
|
1866
|
+
const answered = intent ? !isQuestionOutstanding(intent, new Set(session.answeredQuestionIds ?? [])) : false;
|
|
1867
|
+
const hide = (yes) => yes ? " hidden" : "";
|
|
1868
|
+
return `
|
|
1869
|
+
<section class="pr-helper-why" aria-labelledby="pr-helper-why-heading"${intent ? ` data-intent-question="${escapeHtml$1(intent.id)}"` : ""}>
|
|
1870
|
+
<h4 class="pr-helper-overview-heading" id="pr-helper-why-heading">Why this change?</h4>
|
|
1871
|
+
<p class="pr-helper-why-status pr-helper-why-status-unclear" data-intent-open${hide(answered)}>${MOTIVATION_LABELS.unclear}</p>
|
|
1872
|
+
<p class="pr-helper-why-status pr-helper-why-status-unclear" data-intent-answered${hide(!answered)}>Motivation was unclear when analysed</p>
|
|
1873
|
+
<p class="pr-helper-why-summary">${renderInlineText(motivation.summary)}</p>
|
|
1874
|
+
${motivation.authorQuestion ? `<p class="pr-helper-why-question">${renderInlineText(motivation.authorQuestion)}</p>` : ""}
|
|
1875
|
+
<button type="button" class="pr-helper-why-link" data-overview-nav="questions" data-intent-open${hide(answered)}>Asked in Questions, under Intent</button>
|
|
1876
|
+
<div class="pr-helper-why-answered" data-intent-answered${hide(!answered)}>
|
|
1877
|
+
<p class="pr-helper-why-answered-note">${INTENT_NOTE_COPY}</p>
|
|
1878
|
+
<button type="button" class="pr-helper-why-rerun" data-run-review-again>${RUN_REVIEW_AGAIN}</button>
|
|
1879
|
+
</div>
|
|
1880
|
+
</section>
|
|
1881
|
+
`;
|
|
1882
|
+
}
|
|
1738
1883
|
function renderOverviewTab(session, iconUrl = "", options) {
|
|
1739
1884
|
if (!session) return renderNoReviewState(iconUrl);
|
|
1740
1885
|
if (session.status === "generating") return renderGeneratingOverview(session, iconUrl);
|
|
@@ -1744,6 +1889,12 @@ function renderOverviewTab(session, iconUrl = "", options) {
|
|
|
1744
1889
|
const nextSteps = result.nextSteps.map((step) => `<li class="pr-helper-markdown">${renderMarkdown(step)}</li>`).join("") || "<li>None</li>";
|
|
1745
1890
|
const metrics = deriveReviewMetrics(result);
|
|
1746
1891
|
return `
|
|
1892
|
+
${renderAuthorContext(session)}
|
|
1893
|
+
${renderWhyThisChange(result.motivation, session, result.questions)}
|
|
1894
|
+
<section class="pr-helper-whats-changing" aria-labelledby="pr-helper-whats-changing-heading">
|
|
1895
|
+
<h4 class="pr-helper-overview-heading" id="pr-helper-whats-changing-heading">What's changing</h4>
|
|
1896
|
+
<div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
|
|
1897
|
+
</section>
|
|
1747
1898
|
<div class="pr-helper-risk-headline">
|
|
1748
1899
|
<div class="pr-helper-risk-headline-top">
|
|
1749
1900
|
<span class="pr-helper-risk-dot pr-helper-risk-dot-${result.overallRisk}"></span>
|
|
@@ -1759,7 +1910,6 @@ function renderOverviewTab(session, iconUrl = "", options) {
|
|
|
1759
1910
|
${renderRemainingStat(questionsOutstanding(result.questions, new Set(session.answeredQuestionIds ?? [])), "Questions", "questions", "answered")}
|
|
1760
1911
|
${renderCoverageStat(session, metrics.files)}
|
|
1761
1912
|
</div>
|
|
1762
|
-
<div class="pr-helper-review-summary pr-helper-markdown">${renderMarkdown(result.summary)}</div>
|
|
1763
1913
|
${renderSuggestedPath(result.sections, "Suggested review path")}
|
|
1764
1914
|
${renderOverviewActions(session, options)}
|
|
1765
1915
|
${renderReviewDetails(session, options)}
|
|
@@ -2345,6 +2495,16 @@ function renderDismissToggle(issue, isDismissed, stale, dismissal) {
|
|
|
2345
2495
|
const settled = issue.reconciliationStatus === "resolved";
|
|
2346
2496
|
return `<button type="button" class="pr-helper-issue-dismiss-toggle" data-issue-dismissed="${escapeHtml$1(issue.id)}" data-dismissed="${isDismissed}"${stale ? " data-stale=\"true\"" : ""}${dismissal ? ` data-dismissed-in="${escapeHtml$1(dismissal.sessionId)}"` : ""}${settled ? " data-settled=\"true\"" : ""}>${label}</button>`;
|
|
2347
2497
|
}
|
|
2498
|
+
/**
|
|
2499
|
+
* The group an Intent question lives in, keyed like a theme's so the workspace client finds its
|
|
2500
|
+
* way back to it the same way: by the card's `data-section-id`. Prefixed so no model-chosen theme
|
|
2501
|
+
* id can be the same string.
|
|
2502
|
+
*/
|
|
2503
|
+
var INTENT_QUESTION_GROUP = "prb-intent";
|
|
2504
|
+
/** Where a question card belongs on the Questions tab: Intent, its theme, or neither. */
|
|
2505
|
+
function questionHome(q) {
|
|
2506
|
+
return q.category === "intent" ? INTENT_QUESTION_GROUP : q.sectionId;
|
|
2507
|
+
}
|
|
2348
2508
|
function renderQuestionCard(q, sections, answered, records, now = Date.now(), investigatingId, options) {
|
|
2349
2509
|
const importanceBadge = q.importance ? `<span${pillAttrs(`pr-helper-question-importance-badge pr-helper-question-importance-${q.importance}`, importanceTip(q.importance))}>${escapeHtml$1(q.importance)}</span>` : "";
|
|
2350
2510
|
const jumpLink = renderQuestionJump(q.file, options);
|
|
@@ -2353,7 +2513,7 @@ function renderQuestionCard(q, sections, answered, records, now = Date.now(), in
|
|
|
2353
2513
|
const investigationAnswer = q.investigationAnswer?.trim() ? `<h5>Answer from investigation</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.investigationAnswer)}</p>` : "";
|
|
2354
2514
|
const reconciliationAnswer = q.reconciliationAnswer?.trim() ? `<h5>Answered by the new changes</h5><p class="pr-helper-issue-desc pr-helper-investigation-answer">${renderInlineText(q.reconciliationAnswer)}</p>` : "";
|
|
2355
2515
|
return `
|
|
2356
|
-
<div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${q
|
|
2516
|
+
<div class="pr-helper-question-card${answered ? " pr-helper-question-card-answered" : ""}${q.investigationImpact ? ` pr-helper-issue-investigation-${q.investigationImpact}` : ""}" data-finding-card="${escapeHtml$1(q.id)}"${questionHome(q) ? ` data-section-id="${escapeHtml$1(questionHome(q))}"` : ""}>
|
|
2357
2517
|
<div class="pr-helper-issue-header">
|
|
2358
2518
|
<div class="pr-helper-issue-title-row">
|
|
2359
2519
|
<p class="pr-helper-issue-title">${renderInlineText(q.question)}</p>
|
|
@@ -2429,10 +2589,18 @@ function renderQuestionsTab(session, investigatingId, options) {
|
|
|
2429
2589
|
const open = result.questions.filter((q) => isQuestionOutstanding(q, answered));
|
|
2430
2590
|
const settled = result.questions.filter((q) => !isQuestionOutstanding(q, answered));
|
|
2431
2591
|
const closed = settled.length === 0 ? "" : renderClosedFindings(`Answered (${settled.length})`, settled.map(card).join(""), "questions", open.length === 0);
|
|
2432
|
-
|
|
2592
|
+
const intent = open.filter((q) => q.category === "intent");
|
|
2593
|
+
const intentHtml = result.questions.some((q) => q.category === "intent") ? `
|
|
2594
|
+
<div class="pr-helper-question-group" data-question-group="${INTENT_QUESTION_GROUP}"${intent.length ? "" : " hidden"}>
|
|
2595
|
+
<h5 class="pr-helper-question-group-title">Intent</h5>
|
|
2596
|
+
${intent.map(card).join("")}
|
|
2597
|
+
</div>
|
|
2598
|
+
` : "";
|
|
2599
|
+
const rest = open.filter((q) => q.category !== "intent");
|
|
2600
|
+
if (result.sections.length === 0) return `${progress}${intentHtml}${rest.map(card).join("")}${closed}`;
|
|
2433
2601
|
const grouped = /* @__PURE__ */ new Map();
|
|
2434
2602
|
const ungrouped = [];
|
|
2435
|
-
for (const q of
|
|
2603
|
+
for (const q of rest) if (q.sectionId && sections.has(q.sectionId)) {
|
|
2436
2604
|
const list = grouped.get(q.sectionId) ?? [];
|
|
2437
2605
|
list.push(q);
|
|
2438
2606
|
grouped.set(q.sectionId, list);
|
|
@@ -2447,8 +2615,8 @@ function renderQuestionsTab(session, investigatingId, options) {
|
|
|
2447
2615
|
* card to the bottom of the tab (which is what it did) puts it below the answered drawer,
|
|
2448
2616
|
* outside any theme, where a reload then moves it somewhere else.
|
|
2449
2617
|
*/
|
|
2450
|
-
const themed = new Set(result.questions.filter((q) => q.sectionId).map((q) => q.sectionId));
|
|
2451
|
-
return `${progress}${result.sections.filter((s) => themed.has(s.id)).map((s) => `
|
|
2618
|
+
const themed = new Set(result.questions.filter((q) => q.sectionId && q.category !== "intent").map((q) => q.sectionId));
|
|
2619
|
+
return `${progress}${intentHtml}${result.sections.filter((s) => themed.has(s.id)).map((s) => `
|
|
2452
2620
|
<div class="pr-helper-question-group" data-question-group="${escapeHtml$1(s.id)}"${grouped.has(s.id) ? "" : " hidden"}>
|
|
2453
2621
|
<h5 class="pr-helper-question-group-title">${escapeHtml$1(s.title)}</h5>
|
|
2454
2622
|
${(grouped.get(s.id) ?? []).map(card).join("")}
|
|
@@ -3565,6 +3733,58 @@ function verdictLabel(verdict) {
|
|
|
3565
3733
|
return VERDICT_LABELS[verdict] ?? verdict;
|
|
3566
3734
|
}
|
|
3567
3735
|
//#endregion
|
|
3736
|
+
//#region ../../packages/review-harness/src/domain/kiss/handoff.ts
|
|
3737
|
+
function handoffSource(recommendation) {
|
|
3738
|
+
const { id, title, gist, complexity, whyNotJustified, simplerDirection, draftComment, locations } = recommendation;
|
|
3739
|
+
return {
|
|
3740
|
+
id,
|
|
3741
|
+
title,
|
|
3742
|
+
...gist ? { gist } : {},
|
|
3743
|
+
...complexity ? { complexity } : {},
|
|
3744
|
+
whyNotJustified,
|
|
3745
|
+
simplerDirection,
|
|
3746
|
+
...draftComment ? { draftComment } : {},
|
|
3747
|
+
locations
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* One recommendation: the title, why it matters, the simpler approach, where to start, and the
|
|
3752
|
+
* draft comment when the run wrote one.
|
|
3753
|
+
*
|
|
3754
|
+
* "Why it matters" is the gist, which carries the case in a sentence or two. A schema 1 record has
|
|
3755
|
+
* none, and its `whyNotJustified` is the field of that shape that says why the complexity is not
|
|
3756
|
+
* needed, so it stands in rather than leaving the line out.
|
|
3757
|
+
*/
|
|
3758
|
+
function recommendationMarkdown(recommendation) {
|
|
3759
|
+
const why = recommendation.gist || recommendation.whyNotJustified;
|
|
3760
|
+
const primary = recommendation.locations.find((location) => location.primary) ?? recommendation.locations[0];
|
|
3761
|
+
return [
|
|
3762
|
+
`### ${recommendation.title.trim()}`,
|
|
3763
|
+
why ? `**Why it matters:** ${why.trim()}` : "",
|
|
3764
|
+
`**Simpler approach:** ${recommendation.simplerDirection.trim()}`,
|
|
3765
|
+
primary ? `**Start here:** \`${primary.line ? `${primary.path}:${primary.line}` : primary.path}\`` : "",
|
|
3766
|
+
recommendation.draftComment?.trim() ? `**Draft comment:**\n\n${quote(recommendation.draftComment.trim())}` : ""
|
|
3767
|
+
].filter((part) => part !== "").join("\n\n");
|
|
3768
|
+
}
|
|
3769
|
+
/** The reviewer's handoff: the recommendations they chose to raise, in the order given. */
|
|
3770
|
+
function handoffMarkdown(recommendations) {
|
|
3771
|
+
if (recommendations.length === 0) return "";
|
|
3772
|
+
return [`## Simplification suggestions (${recommendations.length})`, ...recommendations.map(recommendationMarkdown)].join("\n\n");
|
|
3773
|
+
}
|
|
3774
|
+
function quote(text) {
|
|
3775
|
+
return text.split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* The sources as JSON that is safe inside a `<script type="application/json">` element.
|
|
3779
|
+
*
|
|
3780
|
+
* JSON unicode escapes rather than HTML entities: `JSON.parse` restores the original characters,
|
|
3781
|
+
* while `<\/script>` in a title or a draft can no longer end the element early. `&` is escaped too
|
|
3782
|
+
* so no sequence in the text can be read as markup by anything that looks at the raw element.
|
|
3783
|
+
*/
|
|
3784
|
+
function embedHandoffSources(sources) {
|
|
3785
|
+
return JSON.stringify(sources).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
|
|
3786
|
+
}
|
|
3787
|
+
//#endregion
|
|
3568
3788
|
//#region ../../packages/review-harness/src/workspace/kiss_view.ts
|
|
3569
3789
|
/**
|
|
3570
3790
|
* The KISS review, on the page.
|
|
@@ -3628,11 +3848,13 @@ var INTENTS = [{
|
|
|
3628
3848
|
audience: "author",
|
|
3629
3849
|
label: "Review my code",
|
|
3630
3850
|
describes: "Reviewing my code",
|
|
3851
|
+
perspective: "My code",
|
|
3631
3852
|
consequence: "Get direct suggestions to simplify your implementation."
|
|
3632
3853
|
}, {
|
|
3633
3854
|
audience: "reviewer",
|
|
3634
3855
|
label: "Review someone else's code",
|
|
3635
3856
|
describes: "Reviewing someone else's code",
|
|
3857
|
+
perspective: "Someone else's code",
|
|
3636
3858
|
consequence: "Decide which simplifications are worth raising with the author."
|
|
3637
3859
|
}];
|
|
3638
3860
|
/**
|
|
@@ -3648,6 +3870,61 @@ function audienceSentence(audience) {
|
|
|
3648
3870
|
return INTENTS.find((intent) => intent.audience === audience)?.describes ?? "";
|
|
3649
3871
|
}
|
|
3650
3872
|
/**
|
|
3873
|
+
* The two decisions, in each audience's words.
|
|
3874
|
+
*
|
|
3875
|
+
* One stored value, two vocabularies: an author accepts by planning the change and a reviewer by
|
|
3876
|
+
* raising it, and neither declines by dismissing, because declining is not a verdict that the
|
|
3877
|
+
* recommendation was wrong. Total over both unions, so a new audience or decision will not build
|
|
3878
|
+
* without its words. Exported for the client, which relabels a card after a click.
|
|
3879
|
+
*/
|
|
3880
|
+
var DECISION_LABELS = {
|
|
3881
|
+
author: {
|
|
3882
|
+
accepted: "Plan to simplify",
|
|
3883
|
+
declined: "Keep as implemented"
|
|
3884
|
+
},
|
|
3885
|
+
reviewer: {
|
|
3886
|
+
accepted: "Raise with author",
|
|
3887
|
+
declined: "Leave it"
|
|
3888
|
+
}
|
|
3889
|
+
};
|
|
3890
|
+
/** What a card's status pill says. "Open" until decided, then the decision in the audience's words. */
|
|
3891
|
+
function decisionStatusLabel(audience, decision) {
|
|
3892
|
+
return decision === "undecided" ? "Open" : DECISION_LABELS[audience][decision];
|
|
3893
|
+
}
|
|
3894
|
+
/** The line over the list. Shared with the client so a click and a reload say the same thing. */
|
|
3895
|
+
function kissProgressBody(total, open) {
|
|
3896
|
+
return open === 0 ? `<span class="pr-helper-questions-progress-check" aria-hidden="true">\u2713</span><span class="pr-helper-questions-progress-count">All ${total} decided</span>` : `<span class="pr-helper-questions-progress-count">${open} of ${total} undecided</span>`;
|
|
3897
|
+
}
|
|
3898
|
+
/** The drawer's heading. Shared with the client for the same reason. */
|
|
3899
|
+
function decidedDrawerTitle(count) {
|
|
3900
|
+
return `Decided (${count})`;
|
|
3901
|
+
}
|
|
3902
|
+
/**
|
|
3903
|
+
* Whether the reviewer's handoff can be copied, and the sentence that says why or why not.
|
|
3904
|
+
*
|
|
3905
|
+
* Stale comes first: handing off advice about commits the branch has moved past presents old advice
|
|
3906
|
+
* as current, however carefully it was chosen. The sentence is visible text beside the button
|
|
3907
|
+
* rather than a tooltip, so a disabled control always says what would enable it.
|
|
3908
|
+
*/
|
|
3909
|
+
function handoffState(accepted, stale, saving = false) {
|
|
3910
|
+
if (saving) return {
|
|
3911
|
+
enabled: false,
|
|
3912
|
+
note: "Saving your decision. You can copy once it is saved."
|
|
3913
|
+
};
|
|
3914
|
+
if (stale) return {
|
|
3915
|
+
enabled: false,
|
|
3916
|
+
note: "Run KISS again before handing these recommendations off."
|
|
3917
|
+
};
|
|
3918
|
+
if (accepted === 0) return {
|
|
3919
|
+
enabled: false,
|
|
3920
|
+
note: "Choose Raise with author on the recommendations you want to hand off."
|
|
3921
|
+
};
|
|
3922
|
+
return {
|
|
3923
|
+
enabled: true,
|
|
3924
|
+
note: `${accepted === 1 ? "1 recommendation" : `${accepted} recommendations`} to raise with the author.`
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
/**
|
|
3651
3928
|
* The KISS summary: the assessment, what drives it, and the complexity worth keeping.
|
|
3652
3929
|
*
|
|
3653
3930
|
* This is the pane the rail's KISS entry opens, so it carries every state the section can be in.
|
|
@@ -3659,13 +3936,53 @@ function renderKissSummary(view) {
|
|
|
3659
3936
|
if (!run) return renderKissEmpty(view.canRun);
|
|
3660
3937
|
if (run.status === "generating") return renderRunning(run, view.job ?? null);
|
|
3661
3938
|
if (run.status === "failed" || !run.result) return renderFailed(run, view);
|
|
3662
|
-
return `${renderDocHead("KISS review", `${renderAnalysisDetails(provenanceFacts(run))}${KISS_TOGGLE}`)}
|
|
3663
|
-
|
|
3939
|
+
return `${renderDocHead("KISS review", `${renderPerspective(run.audience)}${renderAnalysisDetails(provenanceFacts(run))}${renderRunAgain(view.canRun)}${KISS_TOGGLE}`)}
|
|
3940
|
+
${renderFreshRegion(run, view)}
|
|
3941
|
+
${renderResultBody(run, "header")}
|
|
3942
|
+
${renderRunDialog(view.canRun, run.audience)}`;
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* Whether the answer on the page still describes the branch, for the run the page presents.
|
|
3946
|
+
*
|
|
3947
|
+
* Drawn wherever a result is shown, including the earlier answer under a failed rerun: that answer
|
|
3948
|
+
* is the one being decided and handed off, and without this region the client has nowhere to repaint
|
|
3949
|
+
* a push it notices later.
|
|
3950
|
+
*/
|
|
3951
|
+
function renderFreshRegion(run, view) {
|
|
3952
|
+
return `<div class="prb-kiss-fresh-region" data-kiss-fresh-region data-reviewed-sha="${escapeHtml$1(run.reviewedSha)}"${view.freshness ? ` data-freshness="${escapeHtml$1(JSON.stringify(view.freshness))}"` : ""}${view.dismissedFreshnessSha ? ` data-dismissed-sha="${escapeHtml$1(view.dismissedFreshnessSha)}"` : ""}>${renderKissFreshness(run.reviewedSha, view.freshness ?? null, {
|
|
3664
3953
|
dismissedSha: view.dismissedFreshnessSha,
|
|
3665
3954
|
actions: view.canRun
|
|
3666
|
-
})}</div
|
|
3667
|
-
|
|
3668
|
-
|
|
3955
|
+
})}</div>`;
|
|
3956
|
+
}
|
|
3957
|
+
/**
|
|
3958
|
+
* Whose code the run was about, said as configuration rather than as part of the report.
|
|
3959
|
+
*
|
|
3960
|
+
* A compact line in the header rather than the question left standing under the result: the
|
|
3961
|
+
* choice was made, and what a finished report owes the reader is which choice it was.
|
|
3962
|
+
*/
|
|
3963
|
+
function renderPerspective(audience) {
|
|
3964
|
+
const intent = INTENTS.find((candidate) => candidate.audience === audience);
|
|
3965
|
+
if (!intent) return "";
|
|
3966
|
+
return `<span class="prb-kiss-perspective" data-kiss-perspective="${audience}">Perspective: ${escapeHtml$1(intent.perspective)}</span>`;
|
|
3967
|
+
}
|
|
3968
|
+
/** Opens the run dialog. Beside Analysis details, because running again is a fact about the run too. */
|
|
3969
|
+
function renderRunAgain(canRun) {
|
|
3970
|
+
return canRun ? "<button type=\"button\" class=\"prb-kiss-run-again\" data-kiss-run-open>Run again</button>" : "";
|
|
3971
|
+
}
|
|
3972
|
+
/**
|
|
3973
|
+
* The run control once a result exists: the same question, asked in a dialog.
|
|
3974
|
+
*
|
|
3975
|
+
* The previous run's audience is preselected, and the choice is still shown and still has to be
|
|
3976
|
+
* confirmed by pressing Run. Preselecting carries yesterday's framing forward only as far as the
|
|
3977
|
+
* reader can see it, which is the difference from the remembered default the empty state refuses.
|
|
3978
|
+
*/
|
|
3979
|
+
function renderRunDialog(canRun, audience) {
|
|
3980
|
+
if (!canRun) return "";
|
|
3981
|
+
return `<dialog class="prb-kiss-run-dialog" data-kiss-run-dialog aria-labelledby="prb-kiss-run-dialog-title">
|
|
3982
|
+
<h4 class="prb-kiss-run-dialog-title" id="prb-kiss-run-dialog-title">Run KISS review again</h4>
|
|
3983
|
+
${renderRunControl(true, "Run KISS review", audience)}
|
|
3984
|
+
<button type="button" class="prb-kiss-run-cancel" data-kiss-run-cancel>Cancel</button>
|
|
3985
|
+
</dialog>`;
|
|
3669
3986
|
}
|
|
3670
3987
|
/**
|
|
3671
3988
|
* A completed run's answer, from the outcome down to how it was made.
|
|
@@ -3692,12 +4009,15 @@ ${provenance === "header" ? "" : renderProvenance(run)}`;
|
|
|
3692
4009
|
}
|
|
3693
4010
|
function renderKissFreshness(reviewedSha, freshness, options = {}) {
|
|
3694
4011
|
if (!freshness || freshness.state !== "changed") return "";
|
|
3695
|
-
|
|
4012
|
+
const runAgain = options.actions ? "<button type=\"button\" class=\"prb-kiss-fresh-run\" data-kiss-run-open>Run again</button>" : "";
|
|
4013
|
+
if (options.dismissedSha && options.dismissedSha === freshness.latestSha) return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" data-collapsed role="status">
|
|
4014
|
+
<p><strong>This KISS review describes commit ${escapeHtml$1(reviewedSha.slice(0, 7))}.</strong> The branch has moved on since. ${runAgain}</p>
|
|
4015
|
+
</div>`;
|
|
3696
4016
|
const moved = freshness.rewritten ? "The branch has been rewritten since, so this report describes commits that are no longer on it." : freshness.newCommits !== null ? `There ${freshness.newCommits === 1 ? "has been 1 commit" : `have been ${freshness.newCommits} commits`} on the branch since.` : "The branch has moved on since.";
|
|
3697
4017
|
return `<div class="prb-kiss-fresh" data-kiss-freshness="changed" role="status">
|
|
3698
4018
|
<p><strong>This KISS review describes commit ${escapeHtml$1(reviewedSha.slice(0, 7))}.</strong> ${escapeHtml$1(moved)}</p>
|
|
3699
|
-
<p class="prb-kiss-fresh-note">Running KISS again analyses the branch as it is now. The report below
|
|
3700
|
-
${options.actions && freshness.latestSha ? `<button type="button" class="prb-kiss-fresh-dismiss" data-kiss-freshness-dismiss="${escapeHtml$1(freshness.latestSha)}">Not now</button>` : ""}
|
|
4019
|
+
<p class="prb-kiss-fresh-note">Running KISS again analyses the branch as it is now. The report below and your decisions about it are kept either way.</p>
|
|
4020
|
+
<p class="prb-kiss-fresh-actions">${runAgain}${options.actions && freshness.latestSha ? `<button type="button" class="prb-kiss-fresh-dismiss" data-kiss-freshness-dismiss="${escapeHtml$1(freshness.latestSha)}">Not now</button>` : ""}</p>
|
|
3701
4021
|
</div>`;
|
|
3702
4022
|
}
|
|
3703
4023
|
/**
|
|
@@ -3718,8 +4038,73 @@ function renderKissRecommendations(view) {
|
|
|
3718
4038
|
<h4 class="prb-kiss-outcome-title">Nothing to simplify</h4>
|
|
3719
4039
|
<p class="prb-kiss-outcome-body">The analysis completed and did not identify a materially simpler way to meet these requirements.</p>
|
|
3720
4040
|
</div>`;
|
|
4041
|
+
const decisions = decisionsFor(view, presented);
|
|
4042
|
+
const stale = view.freshness?.state === "changed";
|
|
4043
|
+
const card = (recommendation, rank) => renderRecommendation(recommendation, {
|
|
4044
|
+
runId: presented.id,
|
|
4045
|
+
audience: presented.audience,
|
|
4046
|
+
decision: decisions.get(recommendation.id) ?? "undecided",
|
|
4047
|
+
rank,
|
|
4048
|
+
stale,
|
|
4049
|
+
actions: view.canRun
|
|
4050
|
+
});
|
|
4051
|
+
const recommendations = recommendationsOf(result);
|
|
4052
|
+
const ranked = recommendations.map((recommendation, rank) => ({
|
|
4053
|
+
recommendation,
|
|
4054
|
+
rank
|
|
4055
|
+
}));
|
|
4056
|
+
const open = ranked.filter(({ recommendation }) => !decisions.has(recommendation.id));
|
|
4057
|
+
const decided = ranked.filter(({ recommendation }) => decisions.has(recommendation.id));
|
|
4058
|
+
const total = recommendations.length;
|
|
3721
4059
|
return `${RECS_HEAD}${earlier}${renderWithheldNote(presented)}
|
|
3722
|
-
${
|
|
4060
|
+
${presented.audience === "reviewer" ? renderHandoff(recommendations, decisions, stale) : ""}
|
|
4061
|
+
<div class="pr-helper-questions-progress prb-kiss-progress" data-kiss-progress role="status">${kissProgressBody(total, open.length)}</div>
|
|
4062
|
+
<div class="prb-kiss-open-list" data-kiss-open-list>${open.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
|
|
4063
|
+
<details class="pr-helper-closed-findings prb-kiss-decided" data-kiss-decided${decided.length === 0 ? " hidden" : ""}${open.length === 0 ? " data-nothing-open" : ""}>
|
|
4064
|
+
<summary class="pr-helper-closed-findings-summary" data-kiss-decided-summary>${escapeHtml$1(decidedDrawerTitle(decided.length))}</summary>
|
|
4065
|
+
<div class="pr-helper-closed-findings-list" data-kiss-decided-list>${decided.map(({ recommendation, rank }) => card(recommendation, rank)).join("")}</div>
|
|
4066
|
+
</details>`;
|
|
4067
|
+
}
|
|
4068
|
+
/**
|
|
4069
|
+
* What was decided about the run this pane presents, by recommendation id.
|
|
4070
|
+
*
|
|
4071
|
+
* Only decisions about recommendations the run still lists: a stored decision is never shown on a
|
|
4072
|
+
* card it was not made about, and a recommendation withheld as incomplete has no card to decide on.
|
|
4073
|
+
*/
|
|
4074
|
+
function decisionsFor(view, run) {
|
|
4075
|
+
const listed = new Set(recommendationsOf(run.result).map((recommendation) => recommendation.id));
|
|
4076
|
+
return new Map((view.decisions ?? []).filter((entry) => entry.runId === run.id && listed.has(entry.recommendationId)).map((entry) => [entry.recommendationId, entry.decision]));
|
|
4077
|
+
}
|
|
4078
|
+
/** The decisions made about the run the panes present, for the rail. */
|
|
4079
|
+
function kissDecisionCounts(view) {
|
|
4080
|
+
const run = presentedKissRun(view);
|
|
4081
|
+
if (!run?.result) return null;
|
|
4082
|
+
const total = recommendationsOf(run.result).length;
|
|
4083
|
+
return {
|
|
4084
|
+
total,
|
|
4085
|
+
open: total - decisionsFor(view, run).size
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
/**
|
|
4089
|
+
* The reviewer's handoff: what they chose to raise, as Markdown to paste.
|
|
4090
|
+
*
|
|
4091
|
+
* An export of the decisions on this page, not a report of its own. The Markdown comes from one
|
|
4092
|
+
* helper, over the model's own fields embedded beside the button, so what is copied is never read
|
|
4093
|
+
* back out of the rendered cards. The button's source is filled here for the decisions the page
|
|
4094
|
+
* loaded with, and refilled by the client after every decision.
|
|
4095
|
+
*
|
|
4096
|
+
* Reviewer runs only: an author was never going to hand these to anybody.
|
|
4097
|
+
*/
|
|
4098
|
+
function renderHandoff(recommendations, decisions, stale) {
|
|
4099
|
+
const accepted = recommendations.filter((recommendation) => decisions.get(recommendation.id) === "accepted");
|
|
4100
|
+
const state = handoffState(accepted.length, stale);
|
|
4101
|
+
const sources = recommendations.map(handoffSource);
|
|
4102
|
+
const copy = renderLabelledCopySource(state.enabled ? handoffMarkdown(accepted.map(handoffSource)) : "", "Copy recommendations", `data-kiss-handoff-copy aria-describedby="prb-kiss-handoff-note"${state.enabled ? "" : " disabled"}`);
|
|
4103
|
+
return `<div class="prb-kiss-handoff" data-kiss-handoff${stale ? " data-stale" : ""}>
|
|
4104
|
+
${copy}
|
|
4105
|
+
<p class="prb-kiss-handoff-note" id="prb-kiss-handoff-note" data-kiss-handoff-note>${escapeHtml$1(state.note)}</p>
|
|
4106
|
+
<script type="application/json" data-kiss-handoff-source>${embedHandoffSources(sources)}<\/script>
|
|
4107
|
+
</div>`;
|
|
3723
4108
|
}
|
|
3724
4109
|
/**
|
|
3725
4110
|
* A run whose every recommendation was withheld: incomplete, and said to be.
|
|
@@ -3780,22 +4165,23 @@ ${renderRunControl(canRun, "Run KISS review")}`;
|
|
|
3780
4165
|
* the reader that the page argues with them; one that is plainly waiting on the choice above it
|
|
3781
4166
|
* teaches them what the choice is for.
|
|
3782
4167
|
*
|
|
3783
|
-
* Nothing is preselected
|
|
3784
|
-
*
|
|
3785
|
-
*
|
|
4168
|
+
* Nothing is preselected on a first run: there is no earlier answer, and a default would be the
|
|
4169
|
+
* page guessing whose code this is. A rerun preselects the audience the run it replaces was asked
|
|
4170
|
+
* for, inside a dialog that shows both choices and waits for Run, so the framing carried forward is
|
|
4171
|
+
* one the reader can see and change rather than one remembered for them.
|
|
3786
4172
|
*/
|
|
3787
|
-
function renderRunControl(canRun, label) {
|
|
4173
|
+
function renderRunControl(canRun, label, preselected) {
|
|
3788
4174
|
if (!canRun) return "";
|
|
3789
4175
|
return `<div class="prb-kiss-run" data-kiss-run-control>
|
|
3790
4176
|
<fieldset class="prb-kiss-intents">
|
|
3791
4177
|
<legend>Whose code is this?</legend>
|
|
3792
4178
|
${INTENTS.map((intent) => `<label class="prb-kiss-intent">
|
|
3793
|
-
<input type="radio" name="kiss-audience" value="${intent.audience}" data-kiss-audience>
|
|
4179
|
+
<input type="radio" name="kiss-audience${preselected ? "-again" : ""}" value="${intent.audience}" data-kiss-audience${intent.audience === preselected ? " checked" : ""}>
|
|
3794
4180
|
<span class="prb-kiss-intent-label">${escapeHtml$1(intent.label)}</span>
|
|
3795
4181
|
<span class="prb-kiss-intent-note">${escapeHtml$1(intent.consequence)}</span>
|
|
3796
4182
|
</label>`).join("")}
|
|
3797
4183
|
</fieldset>
|
|
3798
|
-
<button type="button" class="prb-kiss-start" data-kiss-run disabled>${escapeHtml$1(label)}</button>
|
|
4184
|
+
<button type="button" class="prb-kiss-start" data-kiss-run${preselected ? "" : " disabled"}>${escapeHtml$1(label)}</button>
|
|
3799
4185
|
</div>`;
|
|
3800
4186
|
}
|
|
3801
4187
|
/**
|
|
@@ -3833,15 +4219,15 @@ function renderRunning(run, job) {
|
|
|
3833
4219
|
*/
|
|
3834
4220
|
function renderFailed(run, view) {
|
|
3835
4221
|
const standing = view.previous ?? null;
|
|
3836
|
-
return `${renderDocHead("KISS review", KISS_TOGGLE)}
|
|
4222
|
+
return `${renderDocHead("KISS review", `${standing ? renderRunAgain(view.canRun) : ""}${KISS_TOGGLE}`)}
|
|
3837
4223
|
<div class="prb-kiss-outcome" data-review-outcome="failed">
|
|
3838
4224
|
<h4 class="prb-kiss-outcome-title">KISS review could not be completed</h4>
|
|
3839
4225
|
<p class="prb-kiss-outcome-body">${escapeHtml$1(run.error ?? "The analysis did not finish.")}</p>
|
|
3840
4226
|
${standing ? "" : "<p class=\"prb-kiss-outcome-note\">Nothing was recorded from this run.</p>"}
|
|
3841
4227
|
${renderRejectedAnswer(run)}
|
|
3842
4228
|
</div>
|
|
3843
|
-
${standing ? renderEarlier(standing) : ""}
|
|
3844
|
-
${renderRunControl(view.canRun, "Run KISS review again")}`;
|
|
4229
|
+
${standing ? `${renderFreshRegion(standing, view)}${renderEarlier(standing)}` : ""}
|
|
4230
|
+
${standing ? renderRunDialog(view.canRun, run.audience) : renderRunControl(view.canRun, "Run KISS review again", run.audience)}`;
|
|
3845
4231
|
}
|
|
3846
4232
|
/**
|
|
3847
4233
|
* What the agent actually replied, folded away.
|
|
@@ -3935,14 +4321,14 @@ function renderProvenance(run) {
|
|
|
3935
4321
|
*/
|
|
3936
4322
|
function renderJustified(items) {
|
|
3937
4323
|
if (items.length === 0) return "";
|
|
3938
|
-
return `<
|
|
3939
|
-
<
|
|
3940
|
-
${items.map((item) => `<
|
|
3941
|
-
<
|
|
4324
|
+
return `<details class="prb-kiss-justified" data-kiss-justified>
|
|
4325
|
+
<summary class="prb-kiss-justified-summary">Complexity worth keeping (${items.length})</summary>
|
|
4326
|
+
${items.map((item) => `<details class="prb-kiss-kept" data-kiss-kept>
|
|
4327
|
+
<summary class="prb-kiss-kept-summary"><span class="prb-kiss-kept-title">${escapeHtml$1(item.title)}</span>${item.reason ? `<span class="prb-kiss-kept-reason" data-kiss-kept-reason>${escapeHtml$1(item.reason)}</span>` : ""}</summary>
|
|
3942
4328
|
<div class="pr-helper-markdown">${renderMarkdown(item.rationale)}</div>
|
|
3943
4329
|
${renderLocations(item.locations)}
|
|
3944
|
-
</
|
|
3945
|
-
</
|
|
4330
|
+
</details>`).join("")}
|
|
4331
|
+
</details>`;
|
|
3946
4332
|
}
|
|
3947
4333
|
/**
|
|
3948
4334
|
* What validation had to withdraw, disclosed rather than swallowed.
|
|
@@ -3964,26 +4350,20 @@ function renderAudit(run) {
|
|
|
3964
4350
|
${notes.map((note) => `<p>${escapeHtml$1(note)}</p>`).join("")}
|
|
3965
4351
|
</div>`;
|
|
3966
4352
|
}
|
|
3967
|
-
|
|
3968
|
-
* One recommendation, as a decision aid rather than an analysis record.
|
|
3969
|
-
*
|
|
3970
|
-
* The card carries what a reviewer needs to understand and weigh the recommendation: the title,
|
|
3971
|
-
* the case in a sentence or two, the simpler approach, what it gains, the trade-off that could
|
|
3972
|
-
* change the decision, and where to start. Everything that exists to audit or challenge it sits in
|
|
3973
|
-
* one disclosure beneath, closed until asked for.
|
|
3974
|
-
*
|
|
3975
|
-
* Reads both schemas. A record written under schema 1 has no gist, and its `complexity` stands in
|
|
3976
|
-
* as the lede, because it is the only field of that shape that names what the card is about; it is
|
|
3977
|
-
* then not repeated in the disclosure. Stored runs are history and are not rewritten to fit.
|
|
3978
|
-
*/
|
|
3979
|
-
function renderRecommendation(recommendation) {
|
|
4353
|
+
function renderRecommendation(recommendation, context) {
|
|
3980
4354
|
const worthiness = recommendation.commentWorthiness;
|
|
3981
4355
|
const lede = recommendation.gist || recommendation.complexity || "";
|
|
3982
4356
|
const [start, ...rest] = orderedLocations(recommendation.locations);
|
|
3983
|
-
return `<article class="prb-kiss-rec" data-payoff="${recommendation.payoff}"${worthiness ? ` data-worthiness="${worthiness}"` : ""}>
|
|
4357
|
+
return `<article class="prb-kiss-rec" data-finding-card="${escapeHtml$1(recommendation.id)}" data-kiss-rec="${escapeHtml$1(recommendation.id)}" data-kiss-run-id="${escapeHtml$1(context.runId)}" data-audience="${context.audience}" data-rank="${context.rank}" data-decision="${context.decision}" data-payoff="${recommendation.payoff}"${worthiness ? ` data-worthiness="${worthiness}"` : ""}>
|
|
3984
4358
|
<header class="prb-kiss-rec-head">
|
|
3985
|
-
<
|
|
4359
|
+
<div class="prb-kiss-rec-title-row">
|
|
4360
|
+
<h4 class="prb-kiss-rec-title" tabindex="-1">${escapeHtml$1(recommendation.title)}</h4>
|
|
4361
|
+
${renderFindingCopySource(recommendationMarkdown(handoffSource(recommendation)), "recommendation")}
|
|
4362
|
+
${context.actions ? renderAskBuddyMenu("recommendation") : ""}
|
|
4363
|
+
</div>
|
|
3986
4364
|
<div class="prb-kiss-rec-pills">
|
|
4365
|
+
<span class="pr-helper-finding-status prb-kiss-decision-status" data-kiss-status="${context.decision}">${escapeHtml$1(decisionStatusLabel(context.audience, context.decision))}</span>
|
|
4366
|
+
<span class="prb-kiss-stale" data-kiss-stale${context.stale ? "" : " hidden"}>Stale</span>
|
|
3987
4367
|
<span class="prb-kiss-payoff" data-payoff="${recommendation.payoff}">${escapeHtml$1(PAYOFF[recommendation.payoff])}</span>
|
|
3988
4368
|
${worthiness ? `<span class="prb-kiss-worthiness" data-worthiness="${worthiness}">${escapeHtml$1(WORTHINESS[worthiness])}</span>` : ""}
|
|
3989
4369
|
</div>
|
|
@@ -4000,11 +4380,33 @@ function renderRecommendation(recommendation) {
|
|
|
4000
4380
|
${recommendation.draftComment ? `<details class="prb-kiss-draft">
|
|
4001
4381
|
<summary class="prb-kiss-draft-title">Draft comment</summary>
|
|
4002
4382
|
<blockquote class="pr-helper-markdown">${renderMarkdown(recommendation.draftComment)}</blockquote>
|
|
4003
|
-
${renderMessageCopySource(recommendation.draftComment)}
|
|
4004
4383
|
</details>` : ""}
|
|
4384
|
+
${renderCardActions(recommendation, context, Boolean(start))}
|
|
4005
4385
|
</article>`;
|
|
4006
4386
|
}
|
|
4007
4387
|
/**
|
|
4388
|
+
* The card's actions: open the file, copy the draft, and the decision.
|
|
4389
|
+
*
|
|
4390
|
+
* The decision is two toggles rather than a menu, because both answers are equally ordinary and
|
|
4391
|
+
* the reader should see both before choosing. Pressing the pressed one again takes the decision
|
|
4392
|
+
* back, which is how a decision is reversed; nothing is deleted either way.
|
|
4393
|
+
*
|
|
4394
|
+
* Open file presses the card's own Start here row, so there is exactly one way a KISS location
|
|
4395
|
+
* opens. The draft comment copies the Markdown the model wrote, which is what a forge needs.
|
|
4396
|
+
*/
|
|
4397
|
+
function renderCardActions(recommendation, context, hasStart) {
|
|
4398
|
+
const labels = DECISION_LABELS[context.audience];
|
|
4399
|
+
const toggle = (decision) => `<button type="button" class="prb-kiss-decide" data-kiss-decide="${decision}" aria-pressed="${context.decision === decision}">${escapeHtml$1(labels[decision])}</button>`;
|
|
4400
|
+
const openFile = hasStart ? "<button type=\"button\" class=\"prb-kiss-open-file\" data-kiss-open-primary>Open file</button>" : "";
|
|
4401
|
+
const draft = recommendation.draftComment ? renderLabelledCopySource(recommendation.draftComment, "Copy draft comment") : "";
|
|
4402
|
+
const decide = context.actions ? `<div class="prb-kiss-decide-group" role="group" aria-label="${escapeHtml$1(`Decision on ${recommendation.title}`)}">${toggle("accepted")}${toggle("declined")}</div>` : "";
|
|
4403
|
+
if (!openFile && !draft && !decide) return "";
|
|
4404
|
+
return `<div class="prb-kiss-rec-actions" data-kiss-actions>
|
|
4405
|
+
<div class="prb-kiss-rec-actions-aside">${openFile}${draft}</div>
|
|
4406
|
+
${decide}
|
|
4407
|
+
</div>`;
|
|
4408
|
+
}
|
|
4409
|
+
/**
|
|
4008
4410
|
* What acting on it gains, on one line: the estimate first, then the gains.
|
|
4009
4411
|
*
|
|
4010
4412
|
* The estimate says it is one in words beside it, so a phrase like "~35–50 lines" cannot be taken
|
|
@@ -4927,7 +5329,8 @@ function kissViewOf(view) {
|
|
|
4927
5329
|
job: view.kissJob ?? null,
|
|
4928
5330
|
freshness: view.kissFreshness ?? null,
|
|
4929
5331
|
dismissedFreshnessSha: view.dismissedKissFreshnessSha,
|
|
4930
|
-
previous: view.previousKissRun ?? null
|
|
5332
|
+
previous: view.previousKissRun ?? null,
|
|
5333
|
+
decisions: view.kissDecisions ?? []
|
|
4931
5334
|
};
|
|
4932
5335
|
}
|
|
4933
5336
|
function renderKissViews(view) {
|
|
@@ -5434,16 +5837,16 @@ function renderRunningRail(view) {
|
|
|
5434
5837
|
* item that opens a page saying "nothing has run yet" is a promise the rail cannot keep.
|
|
5435
5838
|
*/
|
|
5436
5839
|
function renderKissRail(view) {
|
|
5437
|
-
const
|
|
5840
|
+
const counts = kissDecisionCounts(kissViewOf(view));
|
|
5438
5841
|
return railGroup({
|
|
5439
5842
|
id: "prb-rail-kiss",
|
|
5440
5843
|
title: "KISS",
|
|
5441
5844
|
icon: "kiss",
|
|
5442
5845
|
view: "kiss",
|
|
5443
5846
|
current: view.session.result === null && !showsProgress(view),
|
|
5444
|
-
badge:
|
|
5847
|
+
badge: counts ? recommendationsBadge(counts) : null,
|
|
5445
5848
|
collapses: "the KISS review",
|
|
5446
|
-
body:
|
|
5849
|
+
body: counts ? railLink("kissrecs", "recommendations", "Recommendations", recommendationsBadge(counts)) : ""
|
|
5447
5850
|
});
|
|
5448
5851
|
}
|
|
5449
5852
|
/** The one chevron both twisties draw. Rotated by CSS off `aria-expanded`, never by markup. */
|
|
@@ -5473,17 +5876,20 @@ function railGroup(group) {
|
|
|
5473
5876
|
</div>`;
|
|
5474
5877
|
}
|
|
5475
5878
|
/**
|
|
5476
|
-
* How many recommendations
|
|
5879
|
+
* How many recommendations are still waiting on a decision, in KISS's own noun.
|
|
5477
5880
|
*
|
|
5478
|
-
*
|
|
5479
|
-
*
|
|
5480
|
-
*
|
|
5881
|
+
* The same shape as the Issues and Questions badges: the open count, a tick once every one has been
|
|
5882
|
+
* decided, and the history in the label. "Decided" rather than "resolved", because accepting or
|
|
5883
|
+
* declining a suggestion fixes nothing in the code. A zero total keeps its `0`: a run that
|
|
5884
|
+
* recommended nothing has had nothing decided.
|
|
5481
5885
|
*/
|
|
5482
|
-
function recommendationsBadge(
|
|
5483
|
-
return {
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5886
|
+
function recommendationsBadge(counts) {
|
|
5887
|
+
return remainingBadge({
|
|
5888
|
+
total: counts.total,
|
|
5889
|
+
open: counts.open,
|
|
5890
|
+
done: counts.total - counts.open,
|
|
5891
|
+
reviewerClosed: counts.total - counts.open
|
|
5892
|
+
}, "recommendation", "decided");
|
|
5487
5893
|
}
|
|
5488
5894
|
/** Drawings are facts too, and the label is a sentence, so it has to be a sentence about one. */
|
|
5489
5895
|
/** The map and the implementation diagrams together: everything behind the one rail item. */
|
|
@@ -5604,6 +6010,8 @@ function renderDisclosure(view) {
|
|
|
5604
6010
|
const findings = audit.relocatedFindings > 0 ? ` ${audit.relocatedFindings === 1 ? "One finding" : `${audit.relocatedFindings} findings`} pointed there; the ${audit.relocatedFindings === 1 ? "finding itself was" : "findings themselves were"} kept.` : "";
|
|
5605
6011
|
notes.push(removal + findings);
|
|
5606
6012
|
} else if (audit.relocatedFindings > 0) notes.push(`${audit.relocatedFindings} finding${audit.relocatedFindings === 1 ? "" : "s"} pointed at a file that is not in this change. The finding is kept; its location is not.`);
|
|
6013
|
+
const motivation = motivationNote(audit.motivationCorrection);
|
|
6014
|
+
if (motivation) notes.push(motivation);
|
|
5607
6015
|
if (notes.length === 0) return "";
|
|
5608
6016
|
return `<aside class="prb-disclosure">
|
|
5609
6017
|
<strong>What the harness corrected</strong>
|
|
@@ -5611,6 +6019,21 @@ function renderDisclosure(view) {
|
|
|
5611
6019
|
</aside>`;
|
|
5612
6020
|
}
|
|
5613
6021
|
/**
|
|
6022
|
+
* A refused motivation, described by what kind of claim it was and never by its wording: that
|
|
6023
|
+
* wording is exactly what the check decided the reader should not be shown.
|
|
6024
|
+
*
|
|
6025
|
+
* An unclear motive with no question is not mentioned. Withholding it costs the reader nothing,
|
|
6026
|
+
* since it said nothing they could act on.
|
|
6027
|
+
*/
|
|
6028
|
+
function motivationNote(correction) {
|
|
6029
|
+
if (!correction || correction.reason === "no-question") return null;
|
|
6030
|
+
return `The review gave ${{
|
|
6031
|
+
"missing-quote": "that the author had stated why this change was made, without quoting where",
|
|
6032
|
+
"unverified-quote": "that the author had stated why this change was made, quoting words that are not in the description",
|
|
6033
|
+
"no-evidence": "a likely reason for this change, without evidence for it"
|
|
6034
|
+
}[correction.reason]}, ${correction.outcome === "downgraded" ? "so the reason is shown as unclear, with its question to the author under Intent" : "so no reason is shown"}.`;
|
|
6035
|
+
}
|
|
6036
|
+
/**
|
|
5614
6037
|
* Every changed file, split by whether the guide sends the reviewer there.
|
|
5615
6038
|
*
|
|
5616
6039
|
* The split used to be analysed / not analysed, which was the truth under a file ceiling and is a
|
|
@@ -7070,7 +7493,7 @@ function renderAuthorFilter(authors) {
|
|
|
7070
7493
|
* the reviewer their place in Active. Hidden until the client has counted, so a browser with no
|
|
7071
7494
|
* JavaScript gets every card and no control that does nothing.
|
|
7072
7495
|
*/
|
|
7073
|
-
function renderLoadMore(section) {
|
|
7496
|
+
function renderLoadMore$1(section) {
|
|
7074
7497
|
return `<button type="button" class="prb-index-more" data-load-more="${section}" hidden>Load more</button>`;
|
|
7075
7498
|
}
|
|
7076
7499
|
/**
|
|
@@ -7089,7 +7512,7 @@ function renderSection(section, groups, renderCards) {
|
|
|
7089
7512
|
<div class="prb-cards" data-cards="${section}">
|
|
7090
7513
|
${renderCards(groups)}
|
|
7091
7514
|
</div>
|
|
7092
|
-
${renderLoadMore(section)}
|
|
7515
|
+
${renderLoadMore$1(section)}
|
|
7093
7516
|
</details>`;
|
|
7094
7517
|
}
|
|
7095
7518
|
/**
|
|
@@ -8177,7 +8600,8 @@ ${renderWorkspaceRail({
|
|
|
8177
8600
|
<main class="prb-index prb-notifications-page">
|
|
8178
8601
|
${view.notifications.length === 0 ? `<p class="prb-index-empty">Nothing yet. When a review finishes or fails, it appears here.</p>` : `<ol class="prb-notifications" data-notifications>
|
|
8179
8602
|
${view.notifications.map((notification) => renderEntry(notification, view.gone?.has(notification.id) === true)).join("")}
|
|
8180
|
-
</ol
|
|
8603
|
+
</ol>
|
|
8604
|
+
${renderLoadMore()}`}
|
|
8181
8605
|
<p class="prb-index-note">Kept for thirty days, on this machine only.</p>
|
|
8182
8606
|
</main>
|
|
8183
8607
|
</div>
|
|
@@ -8199,6 +8623,14 @@ function renderReadAll(unread) {
|
|
|
8199
8623
|
</div>`;
|
|
8200
8624
|
}
|
|
8201
8625
|
/**
|
|
8626
|
+
* Load more, which the browser shows once the list holds more than it is showing, as each section
|
|
8627
|
+
* of the Reviews page does. Hidden until the client has counted, so a browser with no JavaScript
|
|
8628
|
+
* gets every entry and no control that does nothing.
|
|
8629
|
+
*/
|
|
8630
|
+
function renderLoadMore() {
|
|
8631
|
+
return `<button type="button" class="prb-index-more" data-notifications-more hidden>Load more</button>`;
|
|
8632
|
+
}
|
|
8633
|
+
/**
|
|
8202
8634
|
* One event: what happened, then what to, then when. The whole row opens the review and so marks it
|
|
8203
8635
|
* read, which is why a row has no Mark read of its own. An event, not a card: nothing about the
|
|
8204
8636
|
* review's findings belongs here.
|
|
@@ -9540,6 +9972,7 @@ async function handle(req, res, options) {
|
|
|
9540
9972
|
}
|
|
9541
9973
|
if (action === "/job") return send(res, 200, jobReport(workspace));
|
|
9542
9974
|
if (action === "/retry") return handleRetry(res, workspace);
|
|
9975
|
+
if (action === "/reanalyse") return handleReanalyse(req, res, workspace);
|
|
9543
9976
|
if (action === "/freshness") return handleFreshness(res, workspace, url.searchParams.get("quiet") === "1");
|
|
9544
9977
|
if (action === "/fetch") return handleFetchOwn(req, res, workspace);
|
|
9545
9978
|
if (action === "/refresh") return handleRefreshPr(res, workspace);
|
|
@@ -10434,7 +10867,8 @@ async function sendWorkspacePage(res, workspace) {
|
|
|
10434
10867
|
kissJob: kissRun ? loadJob(kissRun.jobId) : void 0,
|
|
10435
10868
|
kissFreshness,
|
|
10436
10869
|
dismissedKissFreshnessSha: workspace.dismissedKissFreshnessSha,
|
|
10437
|
-
previousKissRun: previousKissResult(workspace)
|
|
10870
|
+
previousKissRun: previousKissResult(workspace),
|
|
10871
|
+
kissDecisions: workspace.kissDecisions ?? []
|
|
10438
10872
|
});
|
|
10439
10873
|
recordWorkspaceOpened(workspace);
|
|
10440
10874
|
if (freshness) record({
|
|
@@ -10578,7 +11012,7 @@ async function handleFreshness(res, workspace, quiet) {
|
|
|
10578
11012
|
* an answer would be wrong.
|
|
10579
11013
|
*/
|
|
10580
11014
|
async function kissFreshnessOrNull(workspace) {
|
|
10581
|
-
const run =
|
|
11015
|
+
const run = presentedKissRunOf(workspace);
|
|
10582
11016
|
if (!run) return null;
|
|
10583
11017
|
try {
|
|
10584
11018
|
return await checkCodeFreshness(workspace, run.reviewedSha);
|
|
@@ -10697,6 +11131,27 @@ async function handleRetry(res, workspace) {
|
|
|
10697
11131
|
send(res, 200, { ok: true });
|
|
10698
11132
|
}
|
|
10699
11133
|
/**
|
|
11134
|
+
* Analyse this review again, at the commit it describes, with the agent that made it.
|
|
11135
|
+
*
|
|
11136
|
+
* The page's counterpart to `prreviewbuddy review --reanalyse`, and the same function behind it.
|
|
11137
|
+
* It answers once the new job exists, so the page can go and watch it; a refusal before that point
|
|
11138
|
+
* (a job still running, a review being deleted) comes back as one, and nothing has changed.
|
|
11139
|
+
*/
|
|
11140
|
+
async function handleReanalyse(req, res, workspace) {
|
|
11141
|
+
if (req.method !== "POST") return send(res, 405, { error: "Analysing a review again needs a POST." });
|
|
11142
|
+
const current = workspace.jobId ? loadJob(workspace.jobId) : null;
|
|
11143
|
+
if (current && (!isTerminal(current) || isClaimed(current.id))) return send(res, 409, { error: "This review is still being made. Wait for it to finish before analysing it again." });
|
|
11144
|
+
await new Promise((resolve) => {
|
|
11145
|
+
track(reanalyseReview(workspace.id, void 0, () => {
|
|
11146
|
+
send(res, 200, { ok: true });
|
|
11147
|
+
resolve();
|
|
11148
|
+
})).catch((error) => {
|
|
11149
|
+
if (!res.headersSent) send(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
11150
|
+
resolve();
|
|
11151
|
+
});
|
|
11152
|
+
});
|
|
11153
|
+
}
|
|
11154
|
+
/**
|
|
10700
11155
|
* The two audiences, as a closed list this file checks against rather than a string it forwards.
|
|
10701
11156
|
*
|
|
10702
11157
|
* The page sends this, so it is input. A run records who its report was written for, and a value
|
|
@@ -11019,6 +11474,7 @@ async function handlePrefs(req, res) {
|
|
|
11019
11474
|
}
|
|
11020
11475
|
async function handleState(req, res, workspace) {
|
|
11021
11476
|
const body = await readJson(req);
|
|
11477
|
+
if (body !== null && "kissDecision" in body) return handleKissDecision(res, workspace, body.kissDecision);
|
|
11022
11478
|
const saved = STATE_FIELDS.some((field) => body !== null && field in body) ? mutateWorkspace(workspace.id, (current) => applyState(current, body)) : workspace;
|
|
11023
11479
|
if (!saved) return send(res, 404, { error: "No such review" });
|
|
11024
11480
|
send(res, 200, {
|
|
@@ -11028,6 +11484,51 @@ async function handleState(req, res, workspace) {
|
|
|
11028
11484
|
queued: readQueue().includes(saved.id)
|
|
11029
11485
|
});
|
|
11030
11486
|
}
|
|
11487
|
+
var KISS_DECISION_VALUES = [
|
|
11488
|
+
"accepted",
|
|
11489
|
+
"declined",
|
|
11490
|
+
"undecided"
|
|
11491
|
+
];
|
|
11492
|
+
function kissDecisionInput(value) {
|
|
11493
|
+
const input = value;
|
|
11494
|
+
if (typeof input?.runId !== "string" || input.runId === "") return null;
|
|
11495
|
+
if (typeof input.recommendationId !== "string" || input.recommendationId === "") return null;
|
|
11496
|
+
if (!KISS_DECISION_VALUES.includes(input.decision)) return null;
|
|
11497
|
+
return {
|
|
11498
|
+
runId: input.runId,
|
|
11499
|
+
recommendationId: input.recommendationId,
|
|
11500
|
+
decision: input.decision
|
|
11501
|
+
};
|
|
11502
|
+
}
|
|
11503
|
+
var KISS_DECISION_REFUSED = {
|
|
11504
|
+
"unknown-run": "This review has no completed KISS run with that id.",
|
|
11505
|
+
"unknown-recommendation": "That KISS run has no recommendation with that id."
|
|
11506
|
+
};
|
|
11507
|
+
/**
|
|
11508
|
+
* One decision, written onto the review as it is now and answered with what was stored.
|
|
11509
|
+
*
|
|
11510
|
+
* Checked against a fresh read before writing, so a refused decision leaves the file alone, and
|
|
11511
|
+
* checked again inside the write, where the answer is final. The reply carries the stored decision
|
|
11512
|
+
* so the page confirms from the server rather than from what it asked for.
|
|
11513
|
+
*/
|
|
11514
|
+
function handleKissDecision(res, workspace, value) {
|
|
11515
|
+
const input = kissDecisionInput(value);
|
|
11516
|
+
if (!input) return send(res, 400, { error: "A KISS decision names a run, a recommendation, and accepted, declined or undecided." });
|
|
11517
|
+
const before = loadWorkspace(workspace.id);
|
|
11518
|
+
if (!before) return send(res, 404, { error: "No such review" });
|
|
11519
|
+
const early = kissDecisionRefusal(before, input);
|
|
11520
|
+
if (early) return send(res, 400, { error: KISS_DECISION_REFUSED[early] });
|
|
11521
|
+
let refused = null;
|
|
11522
|
+
const saved = mutateWorkspace(workspace.id, (current) => {
|
|
11523
|
+
refused = decideKissRecommendation(current, input);
|
|
11524
|
+
});
|
|
11525
|
+
if (!saved) return send(res, 404, { error: "No such review" });
|
|
11526
|
+
if (refused) return send(res, 400, { error: KISS_DECISION_REFUSED[refused] });
|
|
11527
|
+
send(res, 200, {
|
|
11528
|
+
ok: true,
|
|
11529
|
+
decision: kissDecisionsOf(saved, input.runId).find((entry) => entry.recommendationId === input.recommendationId)?.decision ?? "undecided"
|
|
11530
|
+
});
|
|
11531
|
+
}
|
|
11031
11532
|
/** What `/state` can change. A body naming none of these writes nothing. */
|
|
11032
11533
|
var STATE_FIELDS = [
|
|
11033
11534
|
"answeredQuestionIds",
|