diffninja 0.1.1 → 0.2.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.
@@ -36,6 +36,16 @@ export function renderConnectedPage(csrf) {
36
36
  `<p class="brand">${BRAND_MARK}<span>diffninja</span></p>`,
37
37
  '<h1 id="page-title">Pull request review</h1>',
38
38
  '<p id="page-meta" class="page-meta" hidden></p>',
39
+ '<section id="pr-goal" class="pr-goal" aria-labelledby="pr-goal-heading" hidden>',
40
+ '<h2 id="pr-goal-heading" class="pr-goal-heading">Goal</h2>',
41
+ '<p id="pr-goal-text" class="pr-goal-text"></p>',
42
+ '<p id="pr-goal-by" class="pr-goal-by" hidden></p>',
43
+ "</section>",
44
+ '<details id="pr-description" class="pr-description" hidden>',
45
+ '<summary>Original PR description</summary>',
46
+ '<p class="note">The author’s own words, as written, including any generated notes. Raw HTML is shown as text, images as links, and nothing here is loaded from another host. It is the author’s claim, not evidence that the changes achieve it.</p>',
47
+ '<div id="pr-description-body" class="pr-description-body"></div>',
48
+ "</details>",
39
49
  '<p id="lede" class="lede">Load a GitHub pull request, read its diff in a recommended order, and post your own review through the <span class="mono">gh</span> CLI. Nothing is posted until you press Submit.</p>',
40
50
  '<p id="message-note" class="note" role="status" aria-live="polite" hidden></p>',
41
51
  "</header>",
@@ -840,6 +850,8 @@ function script(csrf) {
840
850
  show(el.lede, !snap);
841
851
  show(el.loadSection, !snap || showLoad);
842
852
  show(el.pageMeta, Boolean(snap));
853
+ show(el.prDescription, Boolean(snap));
854
+ renderDescription();
843
855
  el.pageMeta.textContent = '';
844
856
  el.snapshotBody.textContent = '';
845
857
  if (!snap) {
@@ -854,10 +866,6 @@ function script(csrf) {
854
866
  el.pageMeta.appendChild(make('span', 'meta-item sha', shortSha(snap.headSha)));
855
867
  var login = reviewerLogin();
856
868
  if (login !== '') el.pageMeta.appendChild(make('span', 'meta-item', 'Reviewing as ' + login));
857
- var description = make('details', '');
858
- description.appendChild(make('summary', '', 'Description (the author\u2019s claims)'));
859
- description.appendChild(make('pre', '', typeof snap.body === 'string' && snap.body ? snap.body : 'No description.'));
860
- el.snapshotBody.appendChild(description);
861
869
  var dl = make('dl', 'facts');
862
870
  addLinkFact(dl, 'Pull request', snap.url, typeof snap.url === 'string' ? snap.url : 'unknown');
863
871
  addFact(dl, 'GitHub state', typeof snap.state === 'string' ? snap.state : 'unknown');
@@ -905,6 +913,224 @@ function script(csrf) {
905
913
  return wrap;
906
914
  }
907
915
 
916
+ /* ---------------------------------------------------------- description -- */
917
+
918
+ /**
919
+ * The author's description, rendered from the node tree the server parsed out
920
+ * of their Markdown. This page may not set markup directly, so every node
921
+ * becomes a whitelisted element and every string a text node: raw HTML
922
+ * arrives as text, an image arrives as a link, and an anchor is created only
923
+ * for an http(s) URL. The body is never summarized, shortened or rewritten
924
+ * here — the snapshot's own body text stays the untouched source, and the
925
+ * goal above it is the agent's sentence about the goal, not a rewrite of
926
+ * this text.
927
+ */
928
+ function inlineNodesInto(host, nodes, depth) {
929
+ if (!Array.isArray(nodes) || depth > 16) return;
930
+ for (var i = 0; i < nodes.length; i += 1) {
931
+ var node = nodes[i];
932
+ if (!node || typeof node !== 'object') continue;
933
+ var type = typeof node.t === 'string' ? node.t : '';
934
+ if (type === 'text') {
935
+ if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
936
+ continue;
937
+ }
938
+ if (type === 'code') {
939
+ host.appendChild(make('code', 'pr-inline-code', typeof node.v === 'string' ? node.v : ''));
940
+ continue;
941
+ }
942
+ if (type === 'br') { host.appendChild(document.createElement('br')); continue; }
943
+ if (type === 'strong' || type === 'em' || type === 'del') {
944
+ var wrapper = document.createElement(type);
945
+ inlineNodesInto(wrapper, node.c, depth + 1);
946
+ host.appendChild(wrapper);
947
+ continue;
948
+ }
949
+ if (type === 'link') {
950
+ var inner = document.createDocumentFragment();
951
+ inlineNodesInto(inner, node.c, depth + 1);
952
+ // The URL is checked here too: no anchor is built for anything but http(s).
953
+ if (typeof node.href === 'string' && /^https?:\\/\\//i.test(node.href)) {
954
+ var link = document.createElement('a');
955
+ link.href = node.href;
956
+ link.target = '_blank';
957
+ link.rel = 'noopener noreferrer';
958
+ link.appendChild(inner);
959
+ host.appendChild(link);
960
+ } else {
961
+ host.appendChild(inner);
962
+ }
963
+ continue;
964
+ }
965
+ // Anything this page does not know is shown as its own text.
966
+ if (typeof node.v === 'string') host.appendChild(document.createTextNode(node.v));
967
+ }
968
+ }
969
+
970
+ function blockNodesInto(host, nodes, depth) {
971
+ if (!Array.isArray(nodes) || depth > 16) return;
972
+ for (var i = 0; i < nodes.length; i += 1) {
973
+ var node = nodes[i];
974
+ if (!node || typeof node !== 'object') continue;
975
+ var type = typeof node.t === 'string' ? node.t : '';
976
+ if (type === 'para') {
977
+ var para = document.createElement('p');
978
+ inlineNodesInto(para, node.c, depth + 1);
979
+ host.appendChild(para);
980
+ continue;
981
+ }
982
+ if (type === 'heading') {
983
+ // Demoted: the page owns h1 and h2, so a description's headings stay below them.
984
+ var level = typeof node.d === 'number' && isFinite(node.d) ? Math.min(Math.max(Math.floor(node.d) + 2, 3), 6) : 3;
985
+ var heading = document.createElement('h' + String(level));
986
+ inlineNodesInto(heading, node.c, depth + 1);
987
+ host.appendChild(heading);
988
+ continue;
989
+ }
990
+ if (type === 'quote') {
991
+ var quote = document.createElement('blockquote');
992
+ blockNodesInto(quote, node.c, depth + 1);
993
+ host.appendChild(quote);
994
+ continue;
995
+ }
996
+ if (type === 'code') {
997
+ var lang = typeof node.lang === 'string' ? node.lang : '';
998
+ var pre = make('pre', 'pr-code', typeof node.v === 'string' ? node.v : '');
999
+ if (lang !== '') pre.setAttribute('data-lang', lang);
1000
+ host.appendChild(pre);
1001
+ continue;
1002
+ }
1003
+ if (type === 'raw') {
1004
+ // Markup in the description is shown verbatim, as text.
1005
+ host.appendChild(make('pre', 'pr-raw', typeof node.v === 'string' ? node.v : ''));
1006
+ continue;
1007
+ }
1008
+ if (type === 'rule') { host.appendChild(document.createElement('hr')); continue; }
1009
+ if (type === 'list') {
1010
+ var ordered = node.ordered === true;
1011
+ var list = document.createElement(ordered ? 'ol' : 'ul');
1012
+ if (ordered && typeof node.start === 'number' && isFinite(node.start) && node.start > 1) list.start = Math.floor(node.start);
1013
+ var items = Array.isArray(node.items) ? node.items : [];
1014
+ for (var j = 0; j < items.length; j += 1) {
1015
+ var item = items[j] && typeof items[j] === 'object' ? items[j] : {};
1016
+ var entry = document.createElement('li');
1017
+ if (item.task === true) {
1018
+ entry.className = 'pr-task';
1019
+ var box = document.createElement('input');
1020
+ box.type = 'checkbox';
1021
+ box.disabled = true;
1022
+ box.checked = item.checked === true;
1023
+ box.setAttribute('aria-label', item.checked === true ? 'Task done' : 'Task not done');
1024
+ entry.appendChild(box);
1025
+ }
1026
+ blockNodesInto(entry, item.c, depth + 1);
1027
+ list.appendChild(entry);
1028
+ if (!entry.hasChildNodes()) entry.appendChild(document.createTextNode('(empty)'));
1029
+ }
1030
+ host.appendChild(list);
1031
+ continue;
1032
+ }
1033
+ if (type === 'table') {
1034
+ var align = Array.isArray(node.align) ? node.align : [];
1035
+ var table = make('table', 'pr-table');
1036
+ var head = Array.isArray(node.head) ? node.head : [];
1037
+ if (head.length > 0) {
1038
+ var headRow = document.createElement('tr');
1039
+ for (var h = 0; h < head.length; h += 1) {
1040
+ var th = make('th', 'pr-align-' + (align[h] === 'center' || align[h] === 'right' ? align[h] : 'left'));
1041
+ inlineNodesInto(th, head[h], depth + 1);
1042
+ headRow.appendChild(th);
1043
+ }
1044
+ var thead = document.createElement('thead');
1045
+ thead.appendChild(headRow);
1046
+ table.appendChild(thead);
1047
+ }
1048
+ var rows = Array.isArray(node.rows) ? node.rows : [];
1049
+ var body = document.createElement('tbody');
1050
+ for (var r = 0; r < rows.length; r += 1) {
1051
+ var row = Array.isArray(rows[r]) ? rows[r] : [];
1052
+ var rowElement = document.createElement('tr');
1053
+ for (var c = 0; c < row.length; c += 1) {
1054
+ var td = make('td', 'pr-align-' + (align[c] === 'center' || align[c] === 'right' ? align[c] : 'left'));
1055
+ inlineNodesInto(td, row[c], depth + 1);
1056
+ rowElement.appendChild(td);
1057
+ }
1058
+ body.appendChild(rowElement);
1059
+ }
1060
+ table.appendChild(body);
1061
+ host.appendChild(table);
1062
+ continue;
1063
+ }
1064
+ // An unknown block that carries children or text is still readable as that.
1065
+ if (Array.isArray(node.c)) { blockNodesInto(host, node.c, depth + 1); continue; }
1066
+ if (typeof node.v === 'string' && node.v !== '') host.appendChild(make('p', null, node.v));
1067
+ }
1068
+ }
1069
+
1070
+ function renderDescription() {
1071
+ var snap = snapshot();
1072
+ var host = el.prDescriptionBody;
1073
+ host.textContent = '';
1074
+ var blocks = snap && Array.isArray(snap.bodyBlocks) ? snap.bodyBlocks : null;
1075
+ var plain = snap && typeof snap.body === 'string' ? snap.body.trim() : '';
1076
+ host.className = 'pr-description-body';
1077
+ if (blocks && blocks.length > 0) { blockNodesInto(host, blocks, 0); return; }
1078
+ // No parsed form in this response, or one this server would not hold in full:
1079
+ // the author's own bytes, unwrapped, rather than a cut-off rendering.
1080
+ if (plain !== '') {
1081
+ host.appendChild(make('div', 'pr-plain', plain));
1082
+ if (snap.bodyTooLarge === true) {
1083
+ host.appendChild(make('p', 'note', 'This description is longer than this page will format, so it is shown as the author wrote it, in full and unformatted.'));
1084
+ }
1085
+ return;
1086
+ }
1087
+ setText(host, 'The author has not provided a PR description. No expected outcome was stated.');
1088
+ }
1089
+
1090
+ /**
1091
+ * The goal the reviewing agent recorded for this revision, at the top of the
1092
+ * page: the reading the review was done against, in the agent's own words. It
1093
+ * belongs to the revision on screen — an analysis of another revision shows
1094
+ * nothing rather than a stale goal — and when none was recorded the block
1095
+ * says so instead of describing the pull request itself.
1096
+ */
1097
+ function renderGoal() {
1098
+ var snap = snapshot();
1099
+ // A revision that cannot be reviewed gets no goal block: there is nothing to aim the reading at.
1100
+ var reviewable = Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== '');
1101
+ show(el.prGoal, reviewable);
1102
+ if (!reviewable) return;
1103
+ var current = currentAnalysis();
1104
+ var source = current && current.summary && typeof current.summary === 'object' ? current.summary : null;
1105
+ var text = source && typeof source.text === 'string' ? source.text.trim() : '';
1106
+ setText(el.prGoalText, '');
1107
+ setText(el.prGoalBy, '');
1108
+ show(el.prGoalBy, false);
1109
+ if (text !== '') {
1110
+ el.prGoal.className = 'pr-goal';
1111
+ el.prGoalText.className = 'pr-goal-text';
1112
+ setText(el.prGoalText, text);
1113
+ var by = typeof source.summarizedBy === 'string' ? source.summarizedBy.trim() : '';
1114
+ setText(el.prGoalBy, 'Recorded by ' + (by === '' ? 'your review agent' : by) + ' from the pull request title and description. It is the agent\u2019s reading of the goal, not a check that the changes reach it.');
1115
+ show(el.prGoalBy, true);
1116
+ return;
1117
+ }
1118
+ el.prGoal.className = 'pr-goal is-empty';
1119
+ el.prGoalText.className = 'pr-goal-note';
1120
+ setText(el.prGoalText, goalAbsentReason(snap));
1121
+ }
1122
+
1123
+ /** Why no goal is on screen: the analysis is still coming, is of another revision, or recorded none. */
1124
+ function goalAbsentReason(snap) {
1125
+ if (!analysis || analysisFor !== snap.id) return 'Waiting for the local analysis of this revision\u2026';
1126
+ if (analysis.available !== true) {
1127
+ var reason = typeof analysis.reason === 'string' && analysis.reason !== '' ? analysis.reason : 'no local analysis is attached to this session.';
1128
+ return 'No agent-stated goal for this revision: ' + reason;
1129
+ }
1130
+ if (analysis.snapshotId !== snap.id) return 'The pull request changed; waiting for the analysis of this revision before any goal is shown.';
1131
+ return 'No goal was recorded for this revision. The reviewing agent records one when it finishes its review; the original description below is the author\u2019s own words.';
1132
+ }
1133
+
908
1134
  /* ------------------------------------------------------------ analysis -- */
909
1135
 
910
1136
  var STATUS_ORDER = ['attention', 'uncertain', 'low', 'passed'];
@@ -915,17 +1141,12 @@ function script(csrf) {
915
1141
  return analysis;
916
1142
  }
917
1143
 
918
- function worstStatusFor(path) {
1144
+ function fileHasUncertainty(path) {
919
1145
  var current = currentAnalysis();
920
- if (!current || !Array.isArray(current.hunks)) return '';
921
- var best = STATUS_ORDER.length;
922
- for (var i = 0; i < current.hunks.length; i += 1) {
923
- var hunk = current.hunks[i];
924
- if (!hunk || hunk.file !== path) continue;
925
- var rank = STATUS_ORDER.indexOf(hunk.status);
926
- if (rank >= 0 && rank < best) best = rank;
927
- }
928
- return best < STATUS_ORDER.length ? STATUS_ORDER[best] : '';
1146
+ if (!current || !Array.isArray(current.hunks)) return false;
1147
+ return current.hunks.some(function (hunk) {
1148
+ return hunk && hunk.file === path && hunk.status === 'uncertain';
1149
+ });
929
1150
  }
930
1151
 
931
1152
  function scheduleAnalysis(delay) {
@@ -956,7 +1177,9 @@ function script(csrf) {
956
1177
  var pendingAnswers = analysis && analysis.available === true && analysis.questions && analysis.questions.answered < analysis.questions.total;
957
1178
  var pendingOrder = analysis && analysis.available === true && !(analysis.order && analysis.order.source === 'agent');
958
1179
  var pendingSuggestions = analysis && analysis.available === true && !analysis.suggestions;
959
- if (pendingAnswers || pendingOrder || pendingSuggestions) scheduleAnalysis(10000);
1180
+ // The goal of the revision is recorded when the agent finishes, which can be after its order arrived.
1181
+ var pendingGoal = analysis && analysis.available === true && !analysis.summary;
1182
+ if (pendingAnswers || pendingOrder || pendingSuggestions || pendingGoal) scheduleAnalysis(10000);
960
1183
  });
961
1184
  }
962
1185
 
@@ -979,12 +1202,8 @@ function script(csrf) {
979
1202
  return out;
980
1203
  }
981
1204
 
982
- var STATUS_LABEL = { attention: 'Attention', uncertain: 'Uncertain', low: 'Low', passed: 'Passed' };
983
-
984
- /** A status as a small tinted label; the word is the text, the tint only repeats it. */
985
- function statusTag(status) {
986
- var known = STATUS_ORDER.indexOf(status) >= 0 ? status : 'uncertain';
987
- return make('span', 'status-tag status-' + known, STATUS_LABEL[known]);
1205
+ function uncertaintyTag() {
1206
+ return make('span', 'status-tag status-uncertain', 'Uncertain');
988
1207
  }
989
1208
 
990
1209
  /** A path with its directory muted, so the file name reads first. */
@@ -1013,7 +1232,7 @@ function script(csrf) {
1013
1232
  return go;
1014
1233
  }
1015
1234
 
1016
- /** One station of the rail: where the change is and whether it asks for attention; what to look at lives beside the code. */
1235
+ /** One station of the rail: where the change is; what to look at lives beside the code. */
1017
1236
  function renderHunkEntry(hunk, rank) {
1018
1237
  var entry = make('li', 'order-item');
1019
1238
  entry.dataset.action = 'goto-stop';
@@ -1028,8 +1247,7 @@ function script(csrf) {
1028
1247
  where.appendChild(make('span', 'path-line', ':' + String(hunk.line)));
1029
1248
  main.appendChild(where);
1030
1249
  var meta = make('div', 'order-meta');
1031
- // Only a status that asks for something is shown; low and passed stay quiet.
1032
- if (hunk.status === 'attention' || hunk.status === 'uncertain') meta.appendChild(statusTag(hunk.status));
1250
+ if (hunk.status === 'uncertain') meta.appendChild(uncertaintyTag());
1033
1251
  meta.appendChild(sizeNode(hunk.added, hunk.removed));
1034
1252
  main.appendChild(meta);
1035
1253
  var jump = make('a', 'sr-only', 'Go to change ' + rank + ', ' + hunk.file + ' line ' + hunk.line);
@@ -1117,6 +1335,8 @@ function script(csrf) {
1117
1335
 
1118
1336
  function renderAnalysis() {
1119
1337
  var snap = snapshot();
1338
+ // The goal rides on the analysis, so it re-renders wherever the analysis does — every poll included.
1339
+ renderGoal();
1120
1340
  show(el.analysisSection, Boolean(snap) && !(typeof snap.unavailableReason === 'string' && snap.unavailableReason !== ''));
1121
1341
  el.analysisBody.textContent = '';
1122
1342
  el.analysisActions.textContent = '';
@@ -1867,7 +2087,7 @@ function script(csrf) {
1867
2087
  where.appendChild(make('span', 'path-line', ':' + String(lead.line)));
1868
2088
  head.appendChild(where);
1869
2089
  head.appendChild(sizeOf([stop.segment]));
1870
- if (lead.status === 'attention' || lead.status === 'uncertain') head.appendChild(statusTag(lead.status));
2090
+ if (lead.status === 'uncertain') head.appendChild(uncertaintyTag());
1871
2091
  var tools = make('span', 'file-tools');
1872
2092
  if (hasCallFlow(stop.path)) tools.appendChild(flowButton(stop.path, 'Call flow'));
1873
2093
  head.appendChild(tools);
@@ -1891,8 +2111,7 @@ function script(csrf) {
1891
2111
  var head = make('summary', 'file-head');
1892
2112
  head.appendChild(pathNode(group.path, 'file-path'));
1893
2113
  head.appendChild(sizeOf(segments));
1894
- var worst = worstStatusFor(group.path);
1895
- if (worst === 'attention' || worst === 'uncertain') head.appendChild(statusTag(worst));
2114
+ if (fileHasUncertainty(group.path)) head.appendChild(uncertaintyTag());
1896
2115
  var tools = make('span', 'file-tools');
1897
2116
  if (hasCallFlow(group.path)) tools.appendChild(flowButton(group.path, 'Call flow'));
1898
2117
  head.appendChild(tools);
@@ -2334,6 +2553,11 @@ function script(csrf) {
2334
2553
  el.refreshButton = byId('refresh-button');
2335
2554
  el.pageTitle = byId('page-title');
2336
2555
  el.pageMeta = byId('page-meta');
2556
+ el.prGoal = byId('pr-goal');
2557
+ el.prGoalText = byId('pr-goal-text');
2558
+ el.prGoalBy = byId('pr-goal-by');
2559
+ el.prDescription = byId('pr-description');
2560
+ el.prDescriptionBody = byId('pr-description-body');
2337
2561
  el.lede = byId('lede');
2338
2562
  el.loadSection = byId('load-section');
2339
2563
  el.detailsSection = byId('details-section');
@@ -2451,6 +2675,48 @@ ${BRAND_MARK_STYLES}
2451
2675
  .brand .brand-mark { width: 20px; height: 20px; }
2452
2676
  .page-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--ink-soft); }
2453
2677
  .page-meta a { font-weight: 600; }
2678
+ /* The agent's stated goal, first thing under the masthead: one sentence, with its attribution. */
2679
+ .pr-goal { margin-top: 6px; padding: 14px 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: var(--radius); background: var(--panel); }
2680
+ .pr-goal.is-empty { border-left-color: var(--line-strong); background: var(--sunken); }
2681
+ .pr-goal-heading { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-soft); }
2682
+ .pr-goal-text { margin: 6px 0 0; font-size: 15.5px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; max-width: 90ch; }
2683
+ .pr-goal-note { margin: 6px 0 0; font-size: 13px; color: var(--ink-soft); overflow-wrap: anywhere; max-width: 90ch; }
2684
+ .pr-goal-by { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-faint); overflow-wrap: anywhere; max-width: 90ch; }
2685
+ .pr-description { margin-top: 6px; padding: 12px 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
2686
+ .pr-description > summary { cursor: pointer; font-size: 14px; font-weight: 600; }
2687
+ .pr-description > .note { margin-top: 10px; }
2688
+ .pr-description-body { margin-top: 10px; overflow-wrap: anywhere; font: 14px/1.6 var(--sans); max-width: 90ch; }
2689
+ /* No parsed form in the response, or one too large to hold: the author's own bytes, unwrapped. */
2690
+ .pr-description-body .pr-plain { white-space: pre-wrap; }
2691
+ /* The author's Markdown, laid out as the document it is; every size stays below the page's own headings. */
2692
+ .pr-description-body > *:first-child { margin-top: 0; }
2693
+ .pr-description-body > *:last-child { margin-bottom: 0; }
2694
+ .pr-description-body p, .pr-description-body blockquote, .pr-description-body table, .pr-description-body pre, .pr-description-body ul, .pr-description-body ol { margin: 0 0 10px; }
2695
+ .pr-description-body h3, .pr-description-body h4, .pr-description-body h5, .pr-description-body h6 { margin: 16px 0 6px; line-height: 1.3; }
2696
+ .pr-description-body h3 { font-size: 15px; }
2697
+ .pr-description-body h4 { font-size: 14px; }
2698
+ .pr-description-body h5, .pr-description-body h6 { font-size: 13px; color: var(--ink-soft); }
2699
+ .pr-description-body ul, .pr-description-body ol { padding-left: 22px; }
2700
+ .pr-description-body li { margin: 2px 0; }
2701
+ .pr-description-body li > ul, .pr-description-body li > ol { margin: 2px 0 0; }
2702
+ .pr-description-body li > p { margin: 0 0 6px; }
2703
+ .pr-description-body blockquote { padding: 2px 0 2px 12px; border-left: 3px solid var(--line-strong); color: var(--ink-soft); }
2704
+ .pr-description-body hr { margin: 14px 0; border: 0; border-top: 1px solid var(--line); }
2705
+ .pr-description-body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
2706
+ .pr-description-body code { font: 12.5px var(--mono); background: var(--sunken); border: 1px solid var(--line-soft); border-radius: 4px; padding: 1px 4px; }
2707
+ .pr-description-body pre { padding: 9px 11px; background: var(--sunken); border: 1px solid var(--line); border-radius: 6px; overflow-x: auto; font: 12.5px/1.5 var(--mono); white-space: pre; }
2708
+ .pr-description-body pre code { padding: 0; border: 0; background: none; font: inherit; }
2709
+ /* Raw HTML in a description is shown, not run: dimmed, so it reads as text the author wrote. */
2710
+ .pr-description-body .pr-raw { color: var(--ink-soft); white-space: pre-wrap; }
2711
+ .pr-description-body .pr-task { list-style: none; }
2712
+ .pr-description-body .pr-task input { margin-right: 6px; vertical-align: baseline; }
2713
+ /* The marker sits beside the item's first line, not above the paragraph it labels. */
2714
+ .pr-description-body .pr-task > p:first-of-type { display: inline; margin: 0; }
2715
+ .pr-description-body table.pr-table { width: 100%; border-collapse: collapse; font-size: 13px; display: block; overflow-x: auto; }
2716
+ .pr-description-body .pr-table th, .pr-description-body .pr-table td { border: 1px solid var(--line); padding: 4px 8px; text-align: left; vertical-align: top; }
2717
+ .pr-description-body .pr-table th { background: var(--panel-head, var(--sunken)); font-weight: 650; }
2718
+ .pr-description-body .pr-align-center { text-align: center; }
2719
+ .pr-description-body .pr-align-right { text-align: right; }
2454
2720
  .meta-item.sha { font: 12px var(--mono); padding: 2px 7px; border-radius: 6px; background: var(--neutral-soft); color: var(--ink); }
2455
2721
  .state-badge {
2456
2722
  display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border-radius: 999px;
@@ -2529,7 +2795,6 @@ body.is-busy button { cursor: progress; }
2529
2795
  font-size: 11.5px; font-weight: 600; white-space: nowrap;
2530
2796
  color: var(--ink-soft); background: var(--neutral-soft);
2531
2797
  }
2532
- .status-tag.status-attention { color: var(--alarm); background: var(--alarm-bg); }
2533
2798
  .status-tag.status-uncertain { color: var(--warn); background: var(--warn-soft); }
2534
2799
  .tag-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 6px; }
2535
2800
  .tag {
@@ -1,10 +1,22 @@
1
1
  import { type Server } from "node:http";
2
2
  import { ConnectedReview } from "./github.js";
3
+ import { type MarkdownBlock } from "./markdown.js";
3
4
  import type { ConnectedAnalysisView } from "./connected-analysis.js";
4
5
  export interface ConnectedSession {
5
6
  server: Server;
6
7
  url: string;
7
8
  }
9
+ /**
10
+ * What `GET /api/state` adds to the snapshot: the author's description already
11
+ * parsed into the nodes the page renders. The snapshot's own `body` is left
12
+ * exactly as GitHub returned it; these nodes are how the page shows it.
13
+ */
14
+ export interface ConnectedDescription {
15
+ /** The description as nodes. Absent when the body is empty, or too large to hold in full. */
16
+ readonly bodyBlocks?: readonly MarkdownBlock[];
17
+ /** The body was larger than this parser will hold: the page shows it as plain text rather than cut off. */
18
+ readonly bodyTooLarge?: true;
19
+ }
8
20
  /** Read-only extras a connected session can serve beside the review itself. */
9
21
  export interface ConnectedOptions {
10
22
  /**
@@ -3,6 +3,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { z } from "zod";
4
4
  import { ConnectedReview } from "./github.js";
5
5
  import { renderConnectedPage } from "./connected-html.js";
6
+ import { markdownBlocks } from "./markdown.js";
6
7
  import { reportPolicy } from "./report-pages.js";
7
8
  const MAX_BODY = 256 * 1024;
8
9
  const loadSchema = z.object({ url: z.string() }).strict();
@@ -12,6 +13,25 @@ const reviewSchema = z.object({
12
13
  }).strict();
13
14
  const emptySchema = z.object({}).strict();
14
15
  const tokenSchema = z.string().regex(/^[a-f0-9]{64}$/);
16
+ function described(state) {
17
+ const snap = state.snapshot;
18
+ if (snap === undefined)
19
+ return state;
20
+ const body = snap.body;
21
+ if (body === undefined || body.trim() === "")
22
+ return state;
23
+ const parsed = markdownBlocks(body);
24
+ if (parsed.truncated)
25
+ return { ...state, snapshot: { ...snap, bodyTooLarge: true } };
26
+ return { ...state, snapshot: { ...snap, bodyBlocks: parsed.blocks } };
27
+ }
28
+ function withDescription(response) {
29
+ if ("status" in response)
30
+ return described(response);
31
+ if ("state" in response && response.state !== undefined)
32
+ return { ...response, state: described(response.state) };
33
+ return response;
34
+ }
15
35
  const NO_ANALYSIS = {
16
36
  available: false,
17
37
  reason: "No local analysis is attached to this session.",
@@ -52,7 +72,7 @@ export async function serveConnected(review = new ConnectedReview(), options = {
52
72
  res.setHeader("X-Frame-Options", "DENY");
53
73
  res.setHeader("Referrer-Policy", "no-referrer");
54
74
  res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${csrf}'; style-src 'nonce-${csrf}'; connect-src 'self'; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`);
55
- const json = (code, value) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(value)); };
75
+ const json = (code, value) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(withDescription(value))); };
56
76
  if (!requestIsTrusted(req, origin)) {
57
77
  json(403, { error: "Untrusted Host or Origin." });
58
78
  return;
@@ -0,0 +1,68 @@
1
+ /** End of the inline nodes a description may contain. */
2
+ export type MarkdownInline = {
3
+ readonly t: "text";
4
+ readonly v: string;
5
+ } | {
6
+ readonly t: "code";
7
+ readonly v: string;
8
+ } | {
9
+ readonly t: "br";
10
+ } | {
11
+ readonly t: "strong" | "em" | "del";
12
+ readonly c: readonly MarkdownInline[];
13
+ } | {
14
+ readonly t: "link";
15
+ readonly href: string;
16
+ readonly c: readonly MarkdownInline[];
17
+ };
18
+ export type MarkdownAlign = "left" | "center" | "right";
19
+ /** Inline content of one table cell. */
20
+ export type MarkdownCell = readonly MarkdownInline[];
21
+ export interface MarkdownItem {
22
+ readonly task: boolean;
23
+ readonly checked: boolean;
24
+ readonly c: readonly MarkdownBlock[];
25
+ }
26
+ /** End of the block nodes a description may contain. */
27
+ export type MarkdownBlock = {
28
+ readonly t: "para";
29
+ readonly c: readonly MarkdownInline[];
30
+ } | {
31
+ readonly t: "heading";
32
+ readonly d: number;
33
+ readonly c: readonly MarkdownInline[];
34
+ } | {
35
+ readonly t: "quote";
36
+ readonly c: readonly MarkdownBlock[];
37
+ } | {
38
+ readonly t: "code";
39
+ readonly lang: string;
40
+ readonly v: string;
41
+ } | {
42
+ readonly t: "raw";
43
+ readonly v: string;
44
+ } | {
45
+ readonly t: "rule";
46
+ } | {
47
+ readonly t: "list";
48
+ readonly ordered: boolean;
49
+ readonly start: number;
50
+ readonly items: readonly MarkdownItem[];
51
+ } | {
52
+ readonly t: "table";
53
+ readonly align: readonly MarkdownAlign[];
54
+ readonly head: readonly MarkdownCell[];
55
+ readonly rows: readonly (readonly MarkdownCell[])[];
56
+ };
57
+ /** A parsed description, and whether the walk stopped before the body ended. */
58
+ export interface ParsedDescription {
59
+ readonly blocks: readonly MarkdownBlock[];
60
+ /** True when the description was larger than this parser will hold: `blocks` is only its beginning. */
61
+ readonly truncated: boolean;
62
+ }
63
+ /**
64
+ * The description as renderable nodes, memoized on the last source: the page
65
+ * polls the same state every few seconds, and a description never changes
66
+ * within one snapshot.
67
+ */
68
+ export declare function markdownBlocks(source: string): ParsedDescription;