Add draft-safe inline comments to mobile pull request reviews #166
|
|
@ -21,8 +21,11 @@ submit pull-request reviews. Pull-request replies and mobile My Work issue and P
|
||||||
comments use Gitea's issue-comment API; mobile issue capture requires issue
|
comments use Gitea's issue-comment API; mobile issue capture requires issue
|
||||||
creation and assignment permission. Closing an assigned issue, native Comment,
|
creation and assignment permission. Closing an assigned issue, native Comment,
|
||||||
Approve, and Request changes reviews, and assigned-PR merge require repository
|
Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||||
write permission. The dashboard rechecks the current pull-request head, CI success,
|
write permission. Native Comment, Approve, and Request changes reviews support
|
||||||
draft state, and mergeability immediately before every merge.
|
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
||||||
|
comment path and submits the summary, decision, and inline comments in one review
|
||||||
|
request. The dashboard rechecks the current pull-request head, CI success, draft
|
||||||
|
state, and mergeability immediately before every merge.
|
||||||
Serve the dashboard
|
Serve the dashboard
|
||||||
only to trusted users on its own origin; cross-origin API
|
only to trusted users on its own origin; cross-origin API
|
||||||
access is intentionally disabled. Then start the API and bundled frontend:
|
access is intentionally disabled. Then start the API and bundled frontend:
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||||
.review-diff-line { display:block; min-width:max-content; }
|
.review-diff-line { display:block; min-width:max-content; }
|
||||||
|
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||||
|
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||||
|
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||||
|
.review-inline-composer[hidden] { display:none; }
|
||||||
|
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||||
|
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.review-inline-actions button { min-height:44px; }
|
||||||
.review-diff-line.hunk { color:#93c5fd; }
|
.review-diff-line.hunk { color:#93c5fd; }
|
||||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||||
|
|
@ -426,6 +433,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
|
||||||
<h2>Changed files</h2>
|
<h2>Changed files</h2>
|
||||||
<div id="review-files" class="muted"></div>
|
<div id="review-files" class="muted"></div>
|
||||||
|
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
|
||||||
|
<strong id="review-inline-title">Comment on changed line</strong>
|
||||||
|
<label class="small" id="review-inline-location" for="review-inline-body"></label>
|
||||||
|
<textarea id="review-inline-body" maxlength="10000" placeholder="Write a line-specific comment"></textarea>
|
||||||
|
<div class="review-inline-actions">
|
||||||
|
<button id="save-inline-comment" type="button">Save draft comment</button>
|
||||||
|
<button id="delete-inline-comment" type="button">Delete draft</button>
|
||||||
|
<button id="cancel-inline-comment" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div class="review-progress-actions">
|
<div class="review-progress-actions">
|
||||||
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
<span class="small" id="review-progress" aria-live="polite">0 of 0 files reviewed</span>
|
||||||
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
<button id="next-unreviewed-review" disabled>Next unreviewed</button>
|
||||||
|
|
@ -520,6 +537,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let draft = null;
|
let draft = null;
|
||||||
let reviewFiles = [];
|
let reviewFiles = [];
|
||||||
let selectedReviewHead = '';
|
let selectedReviewHead = '';
|
||||||
|
let activeInlineTarget = null;
|
||||||
let bulkConfirmationPending = false;
|
let bulkConfirmationPending = false;
|
||||||
let bulkMarkPending = false;
|
let bulkMarkPending = false;
|
||||||
let reviewHandoffPending = false;
|
let reviewHandoffPending = false;
|
||||||
|
|
@ -1019,6 +1037,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#new-issue').focus();
|
qs('#new-issue').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inlineAnchor(target) {
|
||||||
|
return {
|
||||||
|
path: target.dataset.reviewFilename,
|
||||||
|
...(target.dataset.newPosition ? { new_position: Number(target.dataset.newPosition) } : {}),
|
||||||
|
...(target.dataset.oldPosition ? { old_position: Number(target.dataset.oldPosition) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameInlineAnchor(comment, anchor) {
|
||||||
|
return comment.path === anchor.path && comment.new_position === anchor.new_position &&
|
||||||
|
comment.old_position === anchor.old_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInlineComposer() {
|
||||||
|
qs('#review-inline-composer').hidden = true;
|
||||||
|
qs('#review-inline-body').value = '';
|
||||||
|
activeInlineTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInlineComposer(target) {
|
||||||
|
if (!draft) return;
|
||||||
|
activeInlineTarget = target;
|
||||||
|
const anchor = inlineAnchor(target);
|
||||||
|
const existing = draft.snapshot().comments.find(comment => sameInlineAnchor(comment, anchor));
|
||||||
|
qs('#review-inline-location').textContent = anchor.path + ' · line ' +
|
||||||
|
(anchor.new_position || anchor.old_position) + (anchor.new_position ? ' (new)' : ' (old)');
|
||||||
|
qs('#review-inline-body').value = existing?.body || '';
|
||||||
|
qs('#delete-inline-comment').disabled = !existing;
|
||||||
|
qs('#review-inline-composer').hidden = false;
|
||||||
|
qs('#review-inline-body').focus();
|
||||||
|
}
|
||||||
|
|
||||||
async function openReviewSheet(item, trigger) {
|
async function openReviewSheet(item, trigger) {
|
||||||
selectedReview = item;
|
selectedReview = item;
|
||||||
reviewTrigger = trigger;
|
reviewTrigger = trigger;
|
||||||
|
|
@ -1042,6 +1092,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#review-handoff-status').textContent = '';
|
qs('#review-handoff-status').textContent = '';
|
||||||
qs('#review-submit-status').textContent = '';
|
qs('#review-submit-status').textContent = '';
|
||||||
qs('#submit-review').disabled = true;
|
qs('#submit-review').disabled = true;
|
||||||
|
closeInlineComposer();
|
||||||
draft = null;
|
draft = null;
|
||||||
reviewFiles = [];
|
reviewFiles = [];
|
||||||
selectedReviewHead = '';
|
selectedReviewHead = '';
|
||||||
|
|
@ -1083,6 +1134,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
note.value = draftSnapshot.notes[note.dataset.reviewFilename] || '';
|
||||||
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
note.addEventListener('input', () => draft.setNote(note.dataset.reviewFilename, note.value));
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('.review-inline-target').forEach(target => {
|
||||||
|
const anchor = inlineAnchor(target);
|
||||||
|
target.classList.toggle('has-draft', draftSnapshot.comments.some(comment =>
|
||||||
|
sameInlineAnchor(comment, anchor)
|
||||||
|
));
|
||||||
|
target.addEventListener('click', () => openInlineComposer(target));
|
||||||
|
});
|
||||||
document.querySelectorAll('.review-mark').forEach(button => {
|
document.querySelectorAll('.review-mark').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
const snapshot = progress.markReviewed(button.dataset.reviewFilename);
|
||||||
|
|
@ -1114,6 +1172,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
draft = null;
|
draft = null;
|
||||||
reviewFiles = [];
|
reviewFiles = [];
|
||||||
selectedReviewHead = '';
|
selectedReviewHead = '';
|
||||||
|
closeInlineComposer();
|
||||||
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
if (reviewTrigger?.isConnected) reviewTrigger.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1481,6 +1540,32 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
});
|
});
|
||||||
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
qs('#review-summary').addEventListener('input', event => draft?.setSummary(event.target.value));
|
||||||
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
qs('#review-decision').addEventListener('change', event => draft?.setDecision(event.target.value));
|
||||||
|
qs('#save-inline-comment').addEventListener('click', () => {
|
||||||
|
if (!draft || !activeInlineTarget) return;
|
||||||
|
const body = qs('#review-inline-body').value.trim();
|
||||||
|
if (!body) {
|
||||||
|
qs('#review-inline-body').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.setInlineComment(inlineAnchor(activeInlineTarget), body);
|
||||||
|
activeInlineTarget.classList.add('has-draft');
|
||||||
|
const target = activeInlineTarget;
|
||||||
|
closeInlineComposer();
|
||||||
|
target.focus();
|
||||||
|
});
|
||||||
|
qs('#delete-inline-comment').addEventListener('click', () => {
|
||||||
|
if (!draft || !activeInlineTarget) return;
|
||||||
|
draft.removeInlineComment(inlineAnchor(activeInlineTarget));
|
||||||
|
activeInlineTarget.classList.remove('has-draft');
|
||||||
|
const target = activeInlineTarget;
|
||||||
|
closeInlineComposer();
|
||||||
|
target.focus();
|
||||||
|
});
|
||||||
|
qs('#cancel-inline-comment').addEventListener('click', () => {
|
||||||
|
const target = activeInlineTarget;
|
||||||
|
closeInlineComposer();
|
||||||
|
target?.focus();
|
||||||
|
});
|
||||||
qs('#submit-review').addEventListener('click', async () => {
|
qs('#submit-review').addEventListener('click', async () => {
|
||||||
if (!draft || !selectedReview || !selectedReviewHead) return;
|
if (!draft || !selectedReview || !selectedReviewHead) return;
|
||||||
const snapshot = draft.snapshot();
|
const snapshot = draft.snapshot();
|
||||||
|
|
@ -1496,6 +1581,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
decision: snapshot.decision,
|
decision: snapshot.decision,
|
||||||
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
body: createReviewController.formatFeedback(snapshot, reviewFiles),
|
||||||
expected_head_sha: selectedReviewHead,
|
expected_head_sha: selectedReviewHead,
|
||||||
|
comments: snapshot.comments,
|
||||||
});
|
});
|
||||||
draft.clear();
|
draft.clear();
|
||||||
progress?.clear();
|
progress?.clear();
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,54 @@ function diffLineClass(line) {
|
||||||
return 'context';
|
return 'context';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDiffLines(lines) {
|
||||||
|
let oldLine = null;
|
||||||
|
let newLine = null;
|
||||||
|
return (lines || []).map(value => {
|
||||||
|
const text = String(value);
|
||||||
|
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||||
|
if (hunk) {
|
||||||
|
oldLine = Number(hunk[1]);
|
||||||
|
newLine = Number(hunk[2]);
|
||||||
|
return { text, kind: 'hunk', commentable: false };
|
||||||
|
}
|
||||||
|
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
|
||||||
|
if (oldLine === null || newLine === null) {
|
||||||
|
return { text, kind: diffLineClass(text), commentable: false };
|
||||||
|
}
|
||||||
|
if (text.startsWith('-')) {
|
||||||
|
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
|
||||||
|
oldLine += 1;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
if (text.startsWith('+')) {
|
||||||
|
const row = { text, kind: 'added', commentable: true, new_position: newLine };
|
||||||
|
newLine += 1;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const row = { text, kind: 'context', commentable: true, new_position: newLine };
|
||||||
|
oldLine += 1;
|
||||||
|
newLine += 1;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDiffFile(file, index, escapeHtml) {
|
function renderDiffFile(file, index, escapeHtml) {
|
||||||
const panelId = 'review-diff-' + index;
|
const panelId = 'review-diff-' + index;
|
||||||
let preview;
|
let preview;
|
||||||
if (file.diff_available) {
|
if (file.diff_available) {
|
||||||
const lines = (file.diff_lines || []).map(line =>
|
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||||
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
|
if (!row.commentable) {
|
||||||
escapeHtml(String(line)) + '</span>'
|
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
|
||||||
).join('');
|
}
|
||||||
|
const position = row.old_position
|
||||||
|
? ' data-old-position="' + row.old_position + '"'
|
||||||
|
: ' data-new-position="' + row.new_position + '"';
|
||||||
|
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||||
|
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||||
|
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||||
|
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
|
||||||
|
}).join('');
|
||||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||||
'</pre>';
|
'</pre>';
|
||||||
|
|
@ -115,17 +155,27 @@ function createProgress({ storage, repository, number, headSha, files }) {
|
||||||
function createDraft({ storage, repository, number, headSha, files }) {
|
function createDraft({ storage, repository, number, headSha, files }) {
|
||||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||||
let draft = { notes: {}, summary: '', decision: 'comment' };
|
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||||
if (saved && typeof saved === 'object') {
|
if (saved && typeof saved === 'object') {
|
||||||
draft.notes = Object.fromEntries(filenames
|
draft.notes = Object.fromEntries(filenames
|
||||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||||
.map(filename => [filename, saved.notes[filename]]));
|
.map(filename => [filename, saved.notes[filename]]));
|
||||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
if (Array.isArray(saved.comments)) {
|
||||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) {
|
draft.comments = saved.comments.filter(comment =>
|
||||||
draft.decision = saved.decision;
|
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
|
||||||
|
Boolean(comment.body.trim()) &&
|
||||||
|
((Number.isInteger(comment.new_position) && !comment.old_position) ||
|
||||||
|
(Number.isInteger(comment.old_position) && !comment.new_position))
|
||||||
|
).map(comment => ({
|
||||||
|
path: comment.path, body: comment.body,
|
||||||
|
...(comment.new_position ? { new_position: comment.new_position } : {}),
|
||||||
|
...(comment.old_position ? { old_position: comment.old_position } : {}),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||||
|
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
|
||||||
}
|
}
|
||||||
} catch (error) { /* start with an empty in-memory draft */ }
|
} catch (error) { /* start with an empty in-memory draft */ }
|
||||||
|
|
||||||
|
|
@ -134,7 +184,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return { notes: { ...draft.notes }, summary: draft.summary, decision: draft.decision };
|
return {
|
||||||
|
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
|
||||||
|
summary: draft.summary, decision: draft.decision,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNote(filename, note) {
|
function setNote(filename, note) {
|
||||||
|
|
@ -145,6 +198,35 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inlineKey(comment) {
|
||||||
|
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInlineComment(anchor, body) {
|
||||||
|
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
|
||||||
|
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
|
||||||
|
? { new_position: anchor.new_position }
|
||||||
|
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
|
||||||
|
? { old_position: anchor.old_position } : null;
|
||||||
|
const text = String(body || '').trim();
|
||||||
|
if (!position || !text) return snapshot();
|
||||||
|
const comment = { path: anchor.path, body: text, ...position };
|
||||||
|
const key = inlineKey(comment);
|
||||||
|
const index = draft.comments.findIndex(item => inlineKey(item) === key);
|
||||||
|
if (index >= 0) draft.comments[index] = comment;
|
||||||
|
else draft.comments.push(comment);
|
||||||
|
persist();
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInlineComment(anchor) {
|
||||||
|
if (!anchor) return snapshot();
|
||||||
|
const key = inlineKey(anchor);
|
||||||
|
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
|
||||||
|
persist();
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
function setSummary(summary) {
|
function setSummary(summary) {
|
||||||
draft.summary = String(summary || '');
|
draft.summary = String(summary || '');
|
||||||
persist();
|
persist();
|
||||||
|
|
@ -160,12 +242,12 @@ function createDraft({ storage, repository, number, headSha, files }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
draft = { notes: {}, summary: '', decision: 'comment' };
|
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { snapshot, setNote, setSummary, setDecision, clear };
|
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFeedback(draft, files) {
|
function formatFeedback(draft, files) {
|
||||||
|
|
@ -208,6 +290,7 @@ async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
createReviewController.renderDiffFile = renderDiffFile;
|
createReviewController.renderDiffFile = renderDiffFile;
|
||||||
|
createReviewController.parseDiffLines = parseDiffLines;
|
||||||
createReviewController.toggleDiff = toggleDiff;
|
createReviewController.toggleDiff = toggleDiff;
|
||||||
createReviewController.createProgress = createProgress;
|
createReviewController.createProgress = createProgress;
|
||||||
createReviewController.createDraft = createDraft;
|
createReviewController.createDraft = createDraft;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ class StaleReviewError(ValueError):
|
||||||
"""Raised before mutation when a pull request head changed during review."""
|
"""Raised before mutation when a pull request head changed during review."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidReviewCommentError(ValueError):
|
||||||
|
"""Raised before mutation when an inline comment cannot target this change."""
|
||||||
|
|
||||||
|
|
||||||
class StalePullError(ValueError):
|
class StalePullError(ValueError):
|
||||||
"""Raised before merge when an assigned pull request head changed."""
|
"""Raised before merge when an assigned pull request head changed."""
|
||||||
|
|
||||||
|
|
@ -694,6 +698,7 @@ async def submit_pull_review(
|
||||||
expected_head_sha: str,
|
expected_head_sha: str,
|
||||||
decision: str,
|
decision: str,
|
||||||
body: str,
|
body: str,
|
||||||
|
comments: list[dict] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
base = f"repos/{repository}/pulls/{number}"
|
base = f"repos/{repository}/pulls/{number}"
|
||||||
pull = await fetch(base)
|
pull = await fetch(base)
|
||||||
|
|
@ -701,6 +706,15 @@ async def submit_pull_review(
|
||||||
current_sha = head.get("sha") if isinstance(head, dict) else None
|
current_sha = head.get("sha") if isinstance(head, dict) else None
|
||||||
if current_sha != expected_head_sha:
|
if current_sha != expected_head_sha:
|
||||||
raise StaleReviewError("Pull request changed while it was being reviewed")
|
raise StaleReviewError("Pull request changed while it was being reviewed")
|
||||||
|
if comments:
|
||||||
|
files = await fetch(f"{base}/files")
|
||||||
|
changed_paths = {
|
||||||
|
item.get("filename")
|
||||||
|
for item in (files if isinstance(files, list) else [])
|
||||||
|
if isinstance(item, dict) and isinstance(item.get("filename"), str)
|
||||||
|
}
|
||||||
|
if any(comment.get("path") not in changed_paths for comment in comments):
|
||||||
|
raise InvalidReviewCommentError("Inline comment path is not in this pull request")
|
||||||
response = await _get_client().post(
|
response = await _get_client().post(
|
||||||
f"/api/v1/{base}/reviews",
|
f"/api/v1/{base}/reviews",
|
||||||
headers=_auth(),
|
headers=_auth(),
|
||||||
|
|
@ -712,6 +726,7 @@ async def submit_pull_review(
|
||||||
"request_changes": "REQUEST_CHANGES",
|
"request_changes": "REQUEST_CHANGES",
|
||||||
}[decision],
|
}[decision],
|
||||||
"commit_id": current_sha,
|
"commit_id": current_sha,
|
||||||
|
**({"comments": comments} if comments else {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
|
||||||
37
src/main.py
37
src/main.py
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
||||||
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
from fastapi import FastAPI, HTTPException, Path as PathParam, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field, PositiveInt, field_validator
|
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||||
|
|
||||||
from src import gitea_proxy
|
from src import gitea_proxy
|
||||||
from src.gitea_proxy import (
|
from src.gitea_proxy import (
|
||||||
|
|
@ -125,10 +125,32 @@ class IssueCreation(BaseModel):
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class PullReviewComment(BaseModel):
|
||||||
|
path: str = Field(min_length=1, max_length=1_000)
|
||||||
|
body: str = Field(min_length=1, max_length=10_000)
|
||||||
|
new_position: PositiveInt | None = None
|
||||||
|
old_position: PositiveInt | None = None
|
||||||
|
|
||||||
|
@field_validator("path", "body")
|
||||||
|
@classmethod
|
||||||
|
def strip_comment_text(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("inline comment values must not be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_one_position(self):
|
||||||
|
if (self.new_position is None) == (self.old_position is None):
|
||||||
|
raise ValueError("inline comments require exactly one old or new position")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class PullReviewSubmission(BaseModel):
|
class PullReviewSubmission(BaseModel):
|
||||||
decision: str
|
decision: str
|
||||||
body: str = Field(max_length=10_000)
|
body: str = Field(max_length=10_000)
|
||||||
expected_head_sha: str
|
expected_head_sha: str
|
||||||
|
comments: list[PullReviewComment] = Field(default_factory=list, max_length=50)
|
||||||
|
|
||||||
@field_validator("decision")
|
@field_validator("decision")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -987,13 +1009,19 @@ async def submit_review(
|
||||||
async def submit_requested_review():
|
async def submit_requested_review():
|
||||||
if not await is_requested_review(repository, number):
|
if not await is_requested_review(repository, number):
|
||||||
raise HTTPException(status_code=404, detail="Review request not found")
|
raise HTTPException(status_code=404, detail="Review request not found")
|
||||||
return await gitea_proxy.submit_pull_review(
|
args = (
|
||||||
repository,
|
repository,
|
||||||
number,
|
number,
|
||||||
submission.expected_head_sha,
|
submission.expected_head_sha,
|
||||||
submission.decision,
|
submission.decision,
|
||||||
submission.body,
|
submission.body,
|
||||||
)
|
)
|
||||||
|
if submission.comments:
|
||||||
|
return await gitea_proxy.submit_pull_review(
|
||||||
|
*args,
|
||||||
|
[comment.model_dump() for comment in submission.comments],
|
||||||
|
)
|
||||||
|
return await gitea_proxy.submit_pull_review(*args)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
|
|
@ -1006,6 +1034,11 @@ async def submit_review(
|
||||||
{"error": "New commits were pushed. Refresh the review before submitting."},
|
{"error": "New commits were pushed. Refresh the review before submitting."},
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
except gitea_proxy.InvalidReviewCommentError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "An inline comment no longer matches this pull request. Refresh the review."},
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error": "The review could not be submitted. Your draft is safe; please retry."},
|
{"error": "The review could not be submitted. Your draft is safe; please retry."},
|
||||||
|
|
|
||||||
|
|
@ -1073,11 +1073,43 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
|
||||||
assert 'Preview truncated' in output["html"]
|
assert 'Preview truncated' in output["html"]
|
||||||
assert 'class="review-mark"' in output["html"]
|
assert 'class="review-mark"' in output["html"]
|
||||||
assert 'data-review-filename="src/<api>.py"' in output["html"]
|
assert 'data-review-filename="src/<api>.py"' in output["html"]
|
||||||
|
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
|
||||||
|
assert 'data-old-position="1"' in output["html"]
|
||||||
|
assert 'data-new-position="1"' in output["html"]
|
||||||
|
assert 'aria-label="Comment on src/<api>.py line 1"' in output["html"]
|
||||||
assert 'Mark reviewed' in output["html"]
|
assert 'Mark reviewed' in output["html"]
|
||||||
assert output["expanded"] == "true"
|
assert output["expanded"] == "true"
|
||||||
assert output["hidden"] is False
|
assert output["hidden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
|
||||||
|
script = f"""
|
||||||
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||||
|
const rows = reviewSheet.parseDiffLines([
|
||||||
|
'@@ -10,3 +20,4 @@ function run()',
|
||||||
|
' context', '-removed', '+added', '+second',
|
||||||
|
String.raw`\\ No newline at end of file`,
|
||||||
|
'@@ -40 +51 @@', '-old tail', '+new tail'
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify(rows));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == [
|
||||||
|
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
|
||||||
|
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
|
||||||
|
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
|
||||||
|
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
|
||||||
|
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
|
||||||
|
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
|
||||||
|
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
|
||||||
|
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
|
||||||
|
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
|
def test_review_progress_is_explicit_and_restores_for_the_same_head_sha():
|
||||||
script = f"""
|
script = f"""
|
||||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||||
|
|
@ -1159,11 +1191,42 @@ process.stdout.write(JSON.stringify(restored));
|
||||||
|
|
||||||
assert json.loads(result.stdout) == {
|
assert json.loads(result.stdout) == {
|
||||||
"notes": {"src/a.py": "Handle the empty state."},
|
"notes": {"src/a.py": "Handle the empty state."},
|
||||||
|
"comments": [],
|
||||||
"summary": "One blocker remains.",
|
"summary": "One blocker remains.",
|
||||||
"decision": "request_changes",
|
"decision": "request_changes",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_draft_persists_edits_and_removes_inline_comments_for_same_head():
|
||||||
|
script = f"""
|
||||||
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k => values.get(k) || null, setItem:(k,v) => values.set(k,v), removeItem:k => values.delete(k)}};
|
||||||
|
const options = {{storage, repository:'stackchain/api', number:7, headSha:'abc123', files:[{{filename:'src/a.py'}}]}};
|
||||||
|
const first = reviewSheet.createDraft(options);
|
||||||
|
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle empty values.');
|
||||||
|
first.setInlineComment({{path:'src/a.py', new_position:42}}, 'Handle null and empty values.');
|
||||||
|
first.setInlineComment({{path:'src/a.py', old_position:9}}, 'Why remove this guard?');
|
||||||
|
const restored = reviewSheet.createDraft(options);
|
||||||
|
const beforeRemove = restored.snapshot().comments;
|
||||||
|
const afterRemove = restored.removeInlineComment({{path:'src/a.py', old_position:9}}).comments;
|
||||||
|
process.stdout.write(JSON.stringify({{beforeRemove, afterRemove}}));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"beforeRemove": [
|
||||||
|
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
|
||||||
|
{"path": "src/a.py", "body": "Why remove this guard?", "old_position": 9},
|
||||||
|
],
|
||||||
|
"afterRemove": [
|
||||||
|
{"path": "src/a.py", "body": "Handle null and empty values.", "new_position": 42},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
|
def test_review_feedback_formats_non_empty_file_notes_in_changed_file_order():
|
||||||
script = f"""
|
script = f"""
|
||||||
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
|
||||||
|
|
@ -1313,6 +1376,22 @@ async def test_mobile_review_sheet_captures_and_safely_hands_off_feedback():
|
||||||
assert "handoffWindow.opener = null" in html
|
assert "handoffWindow.opener = null" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_review_sheet_edits_inline_drafts_and_submits_them_with_review():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="review-inline-composer"' in html
|
||||||
|
assert 'id="review-inline-body"' in html and 'maxlength="10000"' in html
|
||||||
|
assert 'id="save-inline-comment"' in html
|
||||||
|
assert 'id="delete-inline-comment"' in html
|
||||||
|
assert '.review-inline-target' in html and 'min-height:44px' in html
|
||||||
|
assert '.review-inline-composer' in html and 'position:sticky' in html
|
||||||
|
assert "document.querySelectorAll('.review-inline-target')" in html
|
||||||
|
assert "draft.setInlineComment" in html
|
||||||
|
assert "draft.removeInlineComment" in html
|
||||||
|
assert "comments: snapshot.comments" in html
|
||||||
|
|
||||||
|
|
||||||
def test_review_controller_submits_once_while_request_is_in_flight():
|
def test_review_controller_submits_once_while_request_is_in_flight():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
const createReviewController = require({json.dumps(str(REVIEW_SHEET))});
|
||||||
|
|
@ -1389,7 +1468,9 @@ process.stdout.write(JSON.stringify({{removed, draft:draft.snapshot(), progress:
|
||||||
"stackchain.review-draft.v1:stackchain/api#7@abc123",
|
"stackchain.review-draft.v1:stackchain/api#7@abc123",
|
||||||
"stackchain.review-progress.v1:stackchain/api#7@abc123",
|
"stackchain.review-progress.v1:stackchain/api#7@abc123",
|
||||||
]
|
]
|
||||||
assert payload["draft"] == {"notes": {}, "summary": "", "decision": "comment"}
|
assert payload["draft"] == {
|
||||||
|
"notes": {}, "comments": [], "summary": "", "decision": "comment"
|
||||||
|
}
|
||||||
assert payload["progress"]["reviewedCount"] == 0
|
assert payload["progress"]["reviewedCount"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,41 @@ async def test_review_submission_posts_validated_decision_for_requested_current_
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_review_submission_forwards_valid_inline_comments(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def requested(repository, number):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def submit(repository, number, expected_head_sha, decision, body, comments):
|
||||||
|
calls.append((repository, number, expected_head_sha, decision, body, comments))
|
||||||
|
return {"id": 92, "state": "REQUEST_CHANGES", "url": "https://forge.example/reviews/92"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||||
|
json={
|
||||||
|
"decision": "request_changes",
|
||||||
|
"body": "One inline blocker.",
|
||||||
|
"expected_head_sha": "abc123",
|
||||||
|
"comments": [{
|
||||||
|
"path": "src/api.py", "body": "Handle the empty value.",
|
||||||
|
"new_position": 42,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert calls == [(
|
||||||
|
"stackchain/api", 7, "abc123", "request_changes", "One inline blocker.",
|
||||||
|
[{"path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, "old_position": None}],
|
||||||
|
)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
|
async def test_gitea_review_submission_checks_head_then_maps_decision_upstream():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
@ -148,6 +183,38 @@ async def test_gitea_review_submission_checks_head_then_maps_decision_upstream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_review_submission_sends_inline_comments_in_single_review_request():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
if request.url.path.endswith("/files"):
|
||||||
|
return httpx.Response(200, json=[{"filename": "src/api.py"}])
|
||||||
|
if request.method == "GET":
|
||||||
|
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||||
|
return httpx.Response(200, json={"id": 93, "state": "COMMENT"})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
await gitea_proxy.submit_pull_review(
|
||||||
|
"stackchain/api", 7, "abc123", "comment", "Summary",
|
||||||
|
[{"path": "src/api.py", "body": "Handle empty values.", "new_position": 42, "old_position": None}],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert [(request.method, request.url.path) for request in requests] == [
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
|
||||||
|
("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"),
|
||||||
|
]
|
||||||
|
assert requests[-1].content == (
|
||||||
|
b'{"body":"Summary","event":"COMMENT","commit_id":"abc123","comments":'
|
||||||
|
b'[{"path":"src/api.py","body":"Handle empty values.","new_position":42,"old_position":null}]}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_gitea_review_submission_rejects_changed_head_without_posting():
|
async def test_gitea_review_submission_rejects_changed_head_without_posting():
|
||||||
methods = []
|
methods = []
|
||||||
|
|
@ -168,6 +235,32 @@ async def test_gitea_review_submission_rejects_changed_head_without_posting():
|
||||||
assert methods == ["GET"]
|
assert methods == ["GET"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_review_submission_rejects_inline_comment_for_unchanged_path_without_posting():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append((request.method, request.url.path))
|
||||||
|
if request.url.path.endswith("/files"):
|
||||||
|
return httpx.Response(200, json=[{"filename": "src/api.py"}])
|
||||||
|
return httpx.Response(200, json={"head": {"sha": "abc123"}})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
with pytest.raises(gitea_proxy.InvalidReviewCommentError):
|
||||||
|
await gitea_proxy.submit_pull_review(
|
||||||
|
"stackchain/api", 7, "abc123", "comment", "Review note.",
|
||||||
|
[{"path": "src/unknown.py", "body": "Not in this change", "new_position": 4}],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert requests == [
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/pulls/7/files"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
|
async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch):
|
||||||
async def requested(repository, number):
|
async def requested(repository, number):
|
||||||
|
|
@ -195,6 +288,32 @@ async def test_review_submission_returns_conflict_when_pull_head_changed(monkeyp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_review_submission_returns_validation_error_when_inline_path_is_stale(monkeypatch):
|
||||||
|
async def requested(repository, number):
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def submit(*args):
|
||||||
|
raise gitea_proxy.InvalidReviewCommentError("unknown path")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "is_requested_review", requested)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/repos/stackchain/api/pulls/7/review",
|
||||||
|
json={
|
||||||
|
"decision": "comment", "body": "Note", "expected_head_sha": "abc123",
|
||||||
|
"comments": [{"path": "gone.py", "body": "Stale", "new_position": 1}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json() == {
|
||||||
|
"error": "An inline comment no longer matches this pull request. Refresh the review."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
|
async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch):
|
||||||
called = False
|
called = False
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user