Add draft-safe inline comments to mobile pull request reviews #166

Merged
timmy merged 1 commits from timmy/165-mobile-inline-review-comments into main 2026-08-07 04:29:41 +00:00
7 changed files with 436 additions and 16 deletions

View File

@ -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:

View File

@ -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();

View File

@ -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;

View File

@ -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()

View File

@ -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."},

View File

@ -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/&lt;api&gt;.py"' in output["html"] assert 'data-review-filename="src/&lt;api&gt;.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/&lt;api&gt;.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

View File

@ -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