Compare commits

...
Author SHA1 Message Date
rockachopa 0682bbcc2c Merge pull request 'Apply reviewer suggestions across files atomically' (#1387) from timmy/1386-atomic-multi-file-review-suggestions into main
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m21s
CI / release-candidate (push) Successful in 7s
2026-08-25 08:12:56 +00:00
timmy 1c02dcb217 feat: apply review suggestions across files
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m13s
CI / release-candidate (pull_request) Has been skipped
Closes #1386
2026-08-25 08:02:16 +00:00
rockachopa 7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m9s
CI / release-candidate (push) Successful in 7s
2026-08-25 07:26:00 +00:00
timmy 869543e992 feat: batch mobile review suggestions (Closes #1384)
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 07:14:54 +00:00
timmy c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 7s
2026-08-25 06:19:21 +00:00
timmy ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 06:08:22 +00:00
timmy 8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m3s
CI / release-candidate (push) Successful in 7s
2026-08-25 05:21:13 +00:00
timmy b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 05:09:32 +00:00
rockachopa fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
2026-08-25 04:21:41 +00:00
timmy 22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 24s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:57:29 +00:00
14 changed files with 1598 additions and 17 deletions

No files matched your search

+12 -9
View File
@@ -7,12 +7,15 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: pip install -r requirements-audit.txt
@@ -23,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
@@ -33,7 +36,7 @@ jobs:
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: release-bundle
path: dist/
@@ -44,11 +47,11 @@ jobs:
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@@ -66,9 +69,9 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download tested release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
+8 -1
View File
@@ -18,7 +18,14 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
requests, merge assigned pull requests, and submit pull-request reviews. For authored
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
and committed as one atomic branch update. The batch preserves every original blob
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
lines, and verifies the advanced pull head and every final file before reporting success.
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
fix cannot be partially committed.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
+95 -2
View File
@@ -1,4 +1,8 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
@@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
@@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
return controller.isOwned(comment) ?
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
+21
View File
@@ -146,6 +146,12 @@ button:hover { filter: brightness(1.15); }
.issue-comment { min-width:0; overflow-wrap:anywhere; }
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
.comment-owned-actions button { min-height:44px; min-width:72px; }
.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; }
.comment-reactions > button { min-height:44px; min-width:72px; }
.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; }
.comment-reaction-menu[hidden] { display:none; }
.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; }
.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; }
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
@@ -759,6 +765,21 @@ textarea { resize: vertical; min-height: 120px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.pull-feedback-code-actions, .pull-feedback-file-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.pull-feedback-code-actions button, .pull-feedback-file-actions button { min-height:44px; }
.pull-feedback-suggestion-preview { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #31577f; border-radius:10px; background:#07101e; }
.pull-feedback-suggestion-preview[hidden] { display:none; }
.pull-feedback-suggestion-preview h5 { margin:0; }
.pull-feedback-suggestion-change { display:grid; gap:8px; min-width:0; }
.pull-feedback-suggestion-change > div { min-width:0; }
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
.pull-feedback-batch-review[hidden] { display:none; }
.pull-feedback-batch-review h5 { margin:0; }
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
+29 -1
View File
@@ -1741,7 +1741,35 @@
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<button id="make-pull-feedback-fix" type="button" hidden>Make code change</button>
<div class="pull-feedback-code-actions">
<button id="review-pull-feedback-suggestion" type="button" hidden>Review suggested change</button>
<button id="make-pull-feedback-fix" type="button" hidden>Edit full file</button>
</div>
<section class="pull-feedback-suggestion-preview" id="pull-feedback-suggestion-preview" aria-labelledby="pull-feedback-suggestion-heading" hidden>
<h5 id="pull-feedback-suggestion-heading">Suggested change</h5>
<div class="pull-feedback-suggestion-change">
<div><span class="small">Current line</span><pre id="pull-feedback-suggestion-before"></pre></div>
<div><span class="small">Suggested replacement</span><pre id="pull-feedback-suggestion-after"></pre></div>
</div>
<label for="pull-feedback-suggestion-message">Commit message</label>
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
</div>
</section>
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
<ul id="pull-feedback-batch-items"></ul>
<label for="pull-feedback-batch-message">Commit message</label>
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
</div>
</section>
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
<label for="pull-feedback-file-content">File content</label>
+227 -1
View File
@@ -413,11 +413,49 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
const suggestedFile = (file, suggestion) => {
const line = Number(suggestion?.line);
if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null;
const trailingNewline = file.content.endsWith('\n');
const lines = file.content.split('\n');
if (trailingNewline) lines.pop();
if (line > lines.length) return null;
const before = lines[line - 1];
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
const renderBatchButton = () => {
const count = active?.batch ? Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
const button = qs('#review-pull-feedback-batch');
button.hidden = count < 2;
button.textContent = 'Review fixes (' + count + ')';
};
const rebuildBatchFile = entry => {
const trailingNewline = entry.file.content.endsWith('\n');
const lines = entry.file.content.split('\n');
if (trailingNewline) lines.pop();
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
for (const suggestion of ordered) {
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
}
return lines.join('\n') + (trailingNewline ? '\n' : '');
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + '’s feedback';
@@ -434,6 +472,14 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#request-feedback-review').hidden = active.state.posted !== true;
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' &&
active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha &&
comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string';
qs('#review-pull-feedback-suggestion').hidden = !canSuggest;
qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false;
if (canSuggest && globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
}
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
@@ -474,17 +520,171 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
qs('#review-pull-feedback-suggestion').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
return;
}
const button = qs('#review-pull-feedback-suggestion');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current line for review…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
const change = suggestedFile(file, comment.suggestion);
if (!change) throw new Error('The suggested line is no longer available.');
active.suggestion = { file, content:change.content, commentId:comment.id };
qs('#pull-feedback-suggestion-before').textContent = change.before;
qs('#pull-feedback-suggestion-after').textContent = change.after;
qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion';
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.';
button.focus();
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => {
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
if (!active?.suggestion) return;
const pending = active.suggestion;
const path = pending.file.path;
const stagedCount = Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0);
if (stagedCount >= 8) {
qs('#pull-feedback-status').textContent = 'Commit this bounded batch before staging more suggestions.';
return;
}
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
if (entry.file.blob_sha !== pending.file.blob_sha ||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
const comment = current();
entry.suggestions.push({
line:Number(comment.suggestion.line), content:comment.suggestion.content,
commentId:pending.commentId,
});
entry.content = rebuildBatchFile(entry);
if (entry.content === null) {
entry.suggestions.pop();
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
return;
}
active.batch[path] = entry;
clearSuggestion();
renderBatchButton();
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
qs('#next-pull-feedback').focus();
});
qs('#review-pull-feedback-batch').addEventListener('click', () => {
if (!active?.batch) return;
const list = qs('#pull-feedback-batch-items');
list.replaceChildren(...Object.values(active.batch).map(entry => {
const item = doc.createElement('li');
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
return item;
}));
qs('#pull-feedback-batch-review').hidden = false;
qs('#cancel-pull-feedback-batch').hidden = false;
qs('#commit-pull-feedback-batch').hidden = false;
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
qs('#commit-pull-feedback-batch').focus();
});
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
qs('#review-pull-feedback-batch').focus();
});
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
if (!active?.batch) return;
const entries = Object.values(active.batch);
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
const message = qs('#pull-feedback-batch-message').value.trim();
if (count < 2 || !message) return;
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-batch');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
try {
const result = await controller.commitFeedbackBatch(active.item, {
files:entries.map(entry => ({
path:entry.file.path, content:entry.content,
expectedBlobSha:entry.file.blob_sha,
})),
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.batch = {};
save();
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
renderBatchButton();
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
if (!message) {
qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return;
const button = qs('#apply-pull-feedback-suggestion');
button.disabled = true;
const pending = active.suggestion;
qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:pending.file.path, content:pending.content, message,
expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
clearSuggestion();
save();
qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
@@ -495,6 +695,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
active.file = file;
clearSuggestion();
qs('#pull-feedback-file-path').textContent = file.path;
qs('#pull-feedback-file-content').value = file.content;
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
@@ -858,6 +1059,31 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
commitFeedbackBatch(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
files:draft.files.map(file => ({
path:file.path, content:file.content,
expected_blob_sha:file.expectedBlobSha,
})),
suggestion_count:draft.suggestionCount,
message:draft.message, expected_head_sha:draft.expectedHeadSha,
}),
}).then(result => {
const expectedPaths = draft.files.map(file => file.path);
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
!Array.isArray(result.paths) || result.paths.length !== expectedPaths.length ||
result.paths.some((path, index) => path !== expectedPaths[index]) ||
result.suggestion_count !== draft.suggestionCount) {
throw new Error('The suggestion batch was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
+204
View File
@@ -1381,6 +1381,86 @@ def _normalize_issue_comment(comment: dict) -> dict:
}
COMMENT_REACTIONS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes")
async def _conversation_comment(repository: str, number: int, comment_id: int) -> dict:
comment = await fetch(f"repos/{repository}/issues/comments/{comment_id}")
expected_issue_path = f"repos/{repository}/issues/{number}"
issue_url = comment.get("issue_url") if isinstance(comment, dict) else None
safe_issue_url = _safe_gitea_web_url(issue_url)
web_path = urlsplit(safe_issue_url).path.rstrip("/") if safe_issue_url else ""
if (
not isinstance(comment, dict)
or (
_gitea_api_path(issue_url) != expected_issue_path
and not web_path.endswith(f"/{repository}/issues/{number}")
)
):
raise CommentMutationForbiddenError("Comment is not part of this conversation")
return comment
async def comment_reactions(repository: str, number: int, comment_id: int) -> dict:
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Authenticated Gitea user is unavailable")
await _conversation_comment(repository, number, comment_id)
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
response = await _get_client().get(path, headers=_auth())
response.raise_for_status()
payload = response.json()
counts: dict[str, int] = {}
selected: set[str] = set()
for reaction in payload if isinstance(payload, list) else []:
content = reaction.get("content") if isinstance(reaction, dict) else None
author = reaction.get("user") if isinstance(reaction, dict) else None
author_login = author.get("login") if isinstance(author, dict) else None
if content not in COMMENT_REACTIONS or not isinstance(author_login, str) or not author_login:
continue
counts[content] = counts.get(content, 0) + 1
if author_login == login:
selected.add(content)
return {
"comment_id": comment_id,
"reactions": [
{"content": content, "count": counts[content], "selected": content in selected}
for content in COMMENT_REACTIONS
if counts.get(content)
],
}
async def set_comment_reaction(
repository: str, number: int, comment_id: int, content: str, active: bool
) -> dict:
if content not in COMMENT_REACTIONS:
raise ValueError("Unsupported comment reaction")
before = await comment_reactions(repository, number, comment_id)
selected = any(
item["content"] == content and item["selected"]
for item in before["reactions"]
)
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
if selected != active:
client = _get_client()
response = (
await client.post(path, headers=_auth(), json={"content": content})
if active
else await client.delete(path, headers=_auth(), json={"content": content})
)
response.raise_for_status()
confirmed = await comment_reactions(repository, number, comment_id)
confirmed_selected = any(
item["content"] == content and item["selected"]
for item in confirmed["reactions"]
)
if confirmed_selected != active:
raise ValueError("Gitea did not confirm the requested reaction state")
return confirmed
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/comments",
@@ -2339,6 +2419,113 @@ async def commit_authored_pull_feedback_fix(
}
async def commit_authored_pull_feedback_batch(
repository: str,
number: int,
files: list[dict],
suggestion_count: int,
message: str,
expected_head_sha: str,
) -> dict:
"""Commit reviewed replacements across bounded files as one verified commit."""
message = message.strip()
if (
not 2 <= suggestion_count <= 8
or not 1 <= len(files) <= 8
or len(files) > suggestion_count
or not message
or len(message) > 120
):
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
paths = [file.get("path", "") for file in files]
if len(set(paths)) != len(paths):
raise IssueNotAvailableError("Feedback batch contains duplicate files")
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
branch = head.get("ref")
if (
pull.get("state") != "open"
or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
or not isinstance(branch, str)
or not branch
):
raise IssueNotAvailableError("Pull request state changed")
operations = []
submitted: dict[str, str] = {}
total_bytes = 0
for file in files:
path = file.get("path", "")
content = file.get("content", "")
expected_blob_sha = file.get("expected_blob_sha", "")
if not isinstance(content, str):
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
encoded = content.encode("utf-8")
total_bytes += len(encoded)
if len(encoded) > 128 * 1024 or total_bytes > 512 * 1024 or "\x00" in content:
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
current = await authored_pull_feedback_file(
repository, number, path, expected_head_sha
)
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
raise IssueNotAvailableError("File changed or has no new content")
operations.append({
"operation": "update",
"path": path,
"sha": expected_blob_sha,
"content": base64.b64encode(encoded).decode(),
})
submitted[path] = content
response = await _get_client().post(
f"/api/v1/repos/{repository}/contents",
headers=_auth(),
json={"branch": branch, "message": message, "files": operations},
)
if response.status_code in {409, 422}:
raise IssueNotAvailableError("Pull request or file changed")
response.raise_for_status()
payload = response.json()
commit = payload.get("commit") if isinstance(payload, dict) else None
mutation_sha = commit.get("sha") if isinstance(commit, dict) else None
if not isinstance(mutation_sha, str) or not mutation_sha:
raise ValueError("Gitea did not confirm the feedback batch commit")
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
new_head_sha = confirmed_head.get("sha")
if new_head_sha != mutation_sha or new_head_sha == expected_head_sha:
raise ValueError("Gitea did not confirm a new pull request head")
for path, content in submitted.items():
confirmed = await authored_pull_feedback_file(
repository, number, path, new_head_sha
)
if confirmed["content"] != content:
raise ValueError("Gitea did not confirm every committed file")
return {
"repository": repository,
"number": number,
"paths": paths,
"previous_head_sha": expected_head_sha,
"head_sha": new_head_sha,
"suggestion_count": suggestion_count,
"message": message,
}
async def update_authored_pull_branch(
repository: str, number: int, expected_head_sha: str
) -> dict:
@@ -3538,6 +3725,20 @@ async def pull_completion_detail(
}
def _single_line_suggestion(body: str, line: object) -> dict | None:
if not isinstance(line, int) or line <= 0:
return None
blocks = re.findall(r"(?ms)^```suggestion[ \t]*\r?\n(.*?)\r?\n```[ \t]*$", body)
if len(blocks) != 1:
return None
content = blocks[0].replace("\r\n", "\n")
if not content or "\x00" in content or len(content.encode("utf-8")) > 4096:
return None
if len(content.splitlines()) > 20:
return None
return {"line": line, "content": content}
def _normalize_review_comments(comments: object) -> list[dict]:
normalized = []
for comment in (comments if isinstance(comments, list) else [])[:100]:
@@ -3554,6 +3755,9 @@ def _normalize_review_comments(comments: object) -> list[dict]:
position = comment.get("new_position") or comment.get("old_position")
if isinstance(position, int) and position > 0:
item["line"] = position
suggestion = _single_line_suggestion(body, position)
if suggestion is not None:
item["suggestion"] = suggestion
normalized.append(item)
if len(normalized) == 20:
break
+195
View File
@@ -987,6 +987,10 @@ class IssueComment(BaseModel):
return value
class CommentReactionState(BaseModel):
active: bool
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
expected = {
"image/png": {"png"},
@@ -1162,6 +1166,30 @@ class PullFeedbackFileUpdate(BaseModel):
expected_blob_sha: str = Field(min_length=1, max_length=128)
class PullFeedbackBatchFile(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(max_length=131_072)
expected_blob_sha: str = Field(min_length=1, max_length=128)
class PullFeedbackBatchUpdate(BaseModel):
files: list[PullFeedbackBatchFile] = Field(min_length=1, max_length=8)
suggestion_count: int = Field(ge=2, le=8)
message: str = Field(min_length=1, max_length=120)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
@model_validator(mode="after")
def validate_file_manifest(self):
paths = [file.path for file in self.files]
if len(set(paths)) != len(paths):
raise ValueError("feedback batch file paths must be unique")
if len(paths) > self.suggestion_count:
raise ValueError("feedback batch cannot contain more files than suggestions")
return self
class PullContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@@ -6441,6 +6469,144 @@ async def delete_notification_comment(
)
async def _read_conversation_reactions(
repository: str, number: int, comment_id: int
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.comment_reactions(repository, number, comment_id),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
except Exception:
return JSONResponse(
{"error": "Reactions could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
async def _set_conversation_reaction(
repository: str,
number: int,
comment_id: int,
content: str,
active: bool,
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.set_comment_reaction(
repository, number, comment_id, content, active
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception:
return JSONResponse(
{"error": "The reaction could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions")
async def assigned_issue_comment_reactions(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions/{content}")
async def set_assigned_issue_comment_reaction(
state: CommentReactionState,
owner: str,
repo: str,
content: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions")
async def assigned_pull_comment_reactions(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions/{content}")
async def set_assigned_pull_comment_reaction(
state: CommentReactionState,
owner: str,
repo: str,
content: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.get("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions")
async def notification_comment_reactions(
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions/{content}")
async def set_notification_comment_reaction(
state: CommentReactionState,
content: str,
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
async def attach_to_assigned_issue(
request: Request,
@@ -7017,6 +7183,35 @@ async def commit_authored_pull_feedback_fix(
return JSONResponse(result)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-batch")
async def commit_authored_pull_feedback_batch(
update: PullFeedbackBatchUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
try:
result = await asyncio.wait_for(
gitea_proxy.commit_authored_pull_feedback_batch(
f"{owner}/{repo}", number,
[file.model_dump() for file in update.files],
update.suggestion_count, update.message, update.expected_head_sha,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or a suggested file changed. No fixes were committed."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The suggestion batch could not be confirmed. Keep the batch and retry."},
status_code=503, headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -0,0 +1,113 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_stages_and_commits_cross_file_suggestions_atomically(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [
{"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
"suggestion": {"line": 2, "content": " return empty_result()"}},
{"id": 100, "path": "tests/test_api.py", "line": 2, "body": "Prove the typed result.",
"suggestion": {"line": 2, "content": " assert lookup() == empty_result()"}},
],
}],
"files": [
{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]},
{"filename": "tests/test_api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ assert lookup() is None"]},
],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.batchCalls = [];
globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.batchDetail = detail;
globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
batchCalls.push({path, options});
if ((options.method || 'GET') === 'GET') {
if (batchCalls.length === 1) return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
content:'def lookup():\\n return None\\n'};
return {path:'tests/test_api.py', head_sha:'abc1234', blob_sha:'blob2',
content:'def test_lookup():\\n assert lookup() is None\\n'};
}
return {paths:['src/api.py','tests/test_api.py'], suggestion_count:2,
previous_head_sha:'abc1234', head_sha:'def5678'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, batchController,
() => batchItem, () => batchDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 1")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
page.locator("#next-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 2")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
page.locator("#review-pull-feedback-batch").click()
assert page.locator("#pull-feedback-batch-review").is_visible()
assert page.locator("#pull-feedback-batch-items li").count() == 2
page.locator("#commit-pull-feedback-batch").click()
page.wait_for_function("batchCalls.length === 3")
result = page.evaluate("""() => ({
calls:batchCalls, head:batchDetail.head_sha,
reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["reviewHidden"] is True
assert "2 suggested changes committed" in result["status"].lower()
request = result["calls"][2]
assert request["path"].endswith("/feedback-batch")
assert request["options"]["method"] == "POST"
body = request["options"]["body"]
assert '"expected_head_sha":"abc1234"' in body
assert '"files":[{"path":"src/api.py"' in body
assert '"path":"tests/test_api.py"' in body
assert '"suggestion_count":2' in body
assert 'return empty_result()' in body
assert 'assert lookup() == empty_result()' in body
@@ -0,0 +1,103 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_previews_and_applies_review_suggestion(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{
"id": 99, "path": "src/api.py", "line": 2,
"body": "Return the typed empty result.\n```suggestion\n return empty_result()\n```",
"suggestion": {"line": 2, "content": " return empty_result()"},
}, {
"id": 100, "path": "src/api.py", "line": 3,
"body": "Keep the result typed.",
}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.suggestionCalls = [];
globalThis.suggestionItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.suggestionDetail = detail;
globalThis.suggestionController = createPullSheet({fetchJson: async (path, options = {}) => {
suggestionCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123',
content:'def lookup():\\n return None\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, suggestionController,
() => suggestionItem, () => suggestionDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
assert page.locator("#pull-feedback-file-editor").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_visible()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 1")
assert page.locator("#pull-feedback-suggestion-preview").is_visible()
assert page.locator("#pull-feedback-suggestion-before").text_content() == " return None"
assert page.locator("#pull-feedback-suggestion-after").text_content() == " return empty_result()"
assert page.locator("#pull-feedback-file-editor").is_hidden()
page.locator("#next-pull-feedback").click()
assert page.locator("#pull-feedback-suggestion-preview").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_hidden()
page.locator("#previous-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 2")
page.locator("#apply-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 3")
result = page.evaluate("""() => ({
calls:suggestionCalls, head:suggestionDetail.head_sha,
previewHidden:document.querySelector('#pull-feedback-suggestion-preview').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["previewHidden"] is True
assert "suggested change committed" in result["status"].lower()
body = result["calls"][2]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"def lookup():\\n return empty_result()\\n"' in body
@@ -0,0 +1,77 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip(
"packaged mobile comment-reaction journey runs only in its gated CI job",
allow_module_level=True,
)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content(
'<main><div id="status" aria-live="polite"></div>'
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "comment-actions.js")
page.evaluate(
"""
window.reactionCalls = [];
window.offline = false;
const controller = createCommentActions({
getLogin: () => 'timmy',
fetchJson: async (path, options = {}) => {
reactionCalls.push([path, options.method || 'GET']);
return {
comment_id: 91,
reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1,
selected: options.method === 'PUT'}],
};
},
});
document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'});
controller.wire({
root: document.querySelector('#comments'),
getSurface: () => ({
context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}},
status: document.querySelector('#status'),
}),
isOffline: () => window.offline,
escapeHtml: value => String(value),
});
"""
)
trigger = page.get_by_role("button", name="React to this comment")
trigger.click()
menu = page.locator("[data-comment-reaction-menu]")
expect(menu).to_be_visible()
for control in menu.get_by_role("button").all():
box = control.bounding_box()
assert box is not None and box["height"] >= 44
page.get_by_role("button", name="Heart 1").click()
expect(menu).to_be_hidden()
expect(trigger).to_be_focused()
expect(page.locator("#status")).to_have_text("Reaction updated.")
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
assert page.evaluate("window.reactionCalls") == [
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"],
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"],
]
browser.close()
+34 -3
View File
@@ -1,13 +1,44 @@
from pathlib import Path
import re
WORKFLOW = Path(".gitea/workflows/ci.yml")
ACTION_REFERENCE = re.compile(
r"^\s*- uses: (?P<action>[^\s@]+)@(?P<revision>[^\s#]+)\s+# (?P<version>v\d+(?:\.\d+){1,2})$",
re.MULTILINE,
)
DOWNLOAD_ARTIFACT = (
"actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2"
)
def test_ci_uses_gitea_compatible_artifact_action():
text = WORKFLOW.read_text()
assert "actions/upload-artifact@v4" not in text
assert "actions/upload-artifact@v3" in text
assert re.search(
r"actions/upload-artifact@[0-9a-f]{40}\s+# v3\.\d+\.\d+", text
)
def test_ci_pins_every_external_action_to_a_reviewable_commit():
text = WORKFLOW.read_text()
uses_lines = [line for line in text.splitlines() if line.lstrip().startswith("- uses:")]
references = list(ACTION_REFERENCE.finditer(text))
assert len(references) == len(uses_lines)
assert references
for reference in references:
assert re.fullmatch(r"[0-9a-f]{40}", reference["revision"])
def test_ci_grants_repository_write_access_only_to_release_job():
text = WORKFLOW.read_text()
jobs = text[text.index("jobs:") :]
before_release, release = jobs.split(" release-candidate:", 1)
assert "permissions:\n contents: read" in text[: text.index("jobs:")]
assert "contents: write" not in before_release
assert "permissions:\n contents: write" in release
def test_ci_installs_declared_requirements_before_tests():
@@ -40,7 +71,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert DOWNLOAD_ARTIFACT in release
assert (
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
in release
@@ -54,7 +85,7 @@ def test_release_promotion_waits_for_every_packaged_mobile_journey():
release = text[text.index(" release-candidate:") :]
assert "needs: build-release" in browser
assert "actions/download-artifact@v3" in browser
assert DOWNLOAD_ARTIFACT in browser
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
+308
View File
@@ -0,0 +1,308 @@
import json
from pathlib import Path
import httpx
import pytest
from src import gitea_proxy, main
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.anyio
async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "user":
return {"login": "timmy"}
if path == "repos/stackchain/api/issues/comments/91":
return {
"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
}
raise AssertionError(path)
class Response:
def raise_for_status(self):
return None
def json(self):
return [
{"content": "heart", "user": {"login": "alex"}},
{"content": "heart", "user": {"login": "timmy"}},
{"content": "+1", "user": {"login": "alex"}},
{"content": "unsupported", "user": {"login": "alex"}},
{"content": "heart", "user": {}},
]
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {
"comment_id": 91,
"reactions": [
{"content": "+1", "count": 1, "selected": False},
{"content": "heart", "count": 2, "selected": True},
],
}
assert calls == [
("fetch", "user"),
("fetch", "repos/stackchain/api/issues/comments/91"),
("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"),
]
@pytest.mark.anyio
async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch):
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"issue_url": "https://forge.example/git/stackchain/api/issues/7",
}
class Response:
def raise_for_status(self):
return None
def json(self):
return []
class Client:
async def get(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {"comment_id": 91, "reactions": []}
@pytest.mark.anyio
async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch):
payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]]
mutations = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response(payloads.pop(0))
async def post(self, path, headers, json):
mutations.append(("post", path, json))
return Response()
async def delete(self, path, headers, json):
mutations.append(("delete", path, json))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True)
path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert mutations == [("post", path, {"content": "heart"})]
assert result == {
"comment_id": 91,
"reactions": [{"content": "heart", "count": 1, "selected": True}],
}
@pytest.mark.anyio
async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch):
reads = []
writes = []
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def pull_capabilities(repository, number):
return {"can_open": (repository, number) == ("stackchain/api", 8)}
async def notification_target(thread_id):
assert thread_id == 42
return "stackchain/api", 9
async def reactions(repository, number, comment_id):
reads.append((repository, number, comment_id))
return {"comment_id": comment_id, "reactions": []}
async def set_reaction(repository, number, comment_id, content, active):
writes.append((repository, number, comment_id, content, active))
return {
"comment_id": comment_id,
"reactions": [{"content": content, "count": 1, "selected": active}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities)
monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True)
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False)
monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions")
pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions")
update = await client.get("/api/v1/notifications/42/comments/93/reactions")
changed = await client.put(
"/api/v1/notifications/42/comments/93/reactions/heart",
json={"active": True},
)
assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200]
assert reads == [
("stackchain/api", 7, 91),
("stackchain/api", 8, 92),
("stackchain/api", 9, 93),
]
assert writes == [("stackchain/api", 9, 93, "heart", True)]
assert changed.json()["reactions"][0]["selected"] is True
def run_node(script: str) -> dict:
import subprocess
result = subprocess.run(
["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True
)
return json.loads(result.stdout)
def test_comment_action_controller_loads_and_toggles_authoritative_reactions():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const actions=createActions({{
fetchJson:async (path, options={{}})=>{{
calls.push([path, options.method || 'GET', options.body || null]);
return options.method === 'PUT'
? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}}
: {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}};
}},
getLogin:()=> 'timmy',
}});
(async()=>{{
const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}};
const loaded=await actions.loadReactions(context,91);
const changed=await actions.setReaction(context,91,'heart',true);
process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions"
assert output["calls"] == [
[base, "GET", None],
[base + "/heart", "PUT", '{"active":true}'],
]
assert output["loaded"]["reactions"][0]["count"] == 1
assert output["changed"]["reactions"][0]["selected"] is True
assert 'data-comment-reactions-open' in output["html"]
assert 'aria-label="React to this comment"' in output["html"]
def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{
comment_id:91,
reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}],
}})}}));
"""
html = run_node(script)["html"]
assert 'data-comment-reaction="heart"' in html
assert 'aria-pressed="true"' in html
assert "Heart 2" in html
assert 'data-comment-reaction="rocket"' in html
assert "Rocket 0" in html
assert 'data-comment-reactions-close' in html
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".comment-reactions" in css
assert "flex-wrap:wrap" in css
assert ".comment-reaction-menu button" in css
assert "min-height:44px" in css
def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const menu={{hidden:true,innerHTML:''}};
const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}};
const actions=createActions({{
fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}},
getLogin:()=> 'timmy',
}});
actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}});
(async()=>{{
await root.listeners.click({{target:trigger}});
await root.listeners.click({{target:reaction}});
process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/notifications/42/comments/91/reactions"
assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]]
assert output["hidden"] is True
assert output["expanded"] == "false"
assert output["focused"] == 1
assert output["status"] == "Reaction updated."
def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
let calls=0;
const menu={{hidden:true,innerHTML:''}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}};
const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}});
actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}});
(async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})();
"""
assert run_node(script) == {"calls": 0, "status": "Reconnect to react."}
+172
View File
@@ -108,6 +108,150 @@ async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
]
@pytest.mark.anyio
async def test_gitea_commits_cross_file_suggestions_as_one_atomic_commit():
requests = []
pull = {
"number": 7, "state": "open", "merged": False,
"user": {"login": "alex"},
"head": {"sha": "abc1234", "ref": "alex/review-fix", "repo": {"full_name": "stackchain/api"}},
"base": {"repo": {"full_name": "stackchain/api"}},
}
originals = {"src/api.py": ("return empty\n", "blob1"), "tests/test_api.py": ("assert empty\n", "blob2")}
updated = {"src/api.py": ("return handled\n", "blob3"), "tests/test_api.py": ("assert handled\n", "blob4")}
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "alex"})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
return httpx.Response(200, json=pull)
prefix = "/api/v1/repos/stackchain/api/contents/"
if request.url.path.startswith(prefix) and request.method == "GET":
path = request.url.path.removeprefix(prefix)
content, sha = (updated if request.url.params["ref"] == "def5678" else originals)[path]
return httpx.Response(200, json=_content_payload(content, sha))
if request.url.path == "/api/v1/repos/stackchain/api/contents" and request.method == "POST":
body = json.loads(request.content)
assert body == {
"branch": "alex/review-fix",
"message": "fix: apply review suggestions",
"files": [
{"operation": "update", "path": "src/api.py", "sha": "blob1",
"content": base64.b64encode(b"return handled\n").decode()},
{"operation": "update", "path": "tests/test_api.py", "sha": "blob2",
"content": base64.b64encode(b"assert handled\n").decode()},
],
}
pull["head"]["sha"] = "def5678"
return httpx.Response(200, json={"commit": {"sha": "def5678"}})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.commit_authored_pull_feedback_batch(
"stackchain/api", 7,
[
{"path": "src/api.py", "content": "return handled\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert handled\n", "expected_blob_sha": "blob2"},
],
2, "fix: apply review suggestions", "abc1234",
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7,
"paths": ["src/api.py", "tests/test_api.py"],
"previous_head_sha": "abc1234", "head_sha": "def5678",
"suggestion_count": 2, "message": "fix: apply review suggestions",
}
assert len([request for request in requests if request == ("POST", "/api/v1/repos/stackchain/api/contents")]) == 1
assert not [request for request in requests if request[0] == "PUT"]
@pytest.mark.anyio
async def test_feedback_batch_api_requires_at_least_two_suggestions(monkeypatch):
called = False
async def commit(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
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/feedback-batch",
json={"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 1,
"file": {"path": "src/api.py", "content": "one\n", "expected_blob_sha": "blob1"}},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_feedback_batch_api_accepts_unique_cross_file_manifest(monkeypatch):
calls = []
async def commit(*args):
calls.append(args)
return {
"repository": "stackchain/api", "number": 7,
"paths": ["src/api.py", "tests/test_api.py"],
"previous_head_sha": "abc1234", "head_sha": "def5678",
"suggestion_count": 2, "message": "fix: suggestions",
}
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
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/feedback-batch",
json={
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
"files": [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
],
},
)
assert response.status_code == 200
assert response.json()["paths"] == ["src/api.py", "tests/test_api.py"]
assert calls[0][2] == [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
]
@pytest.mark.anyio
async def test_feedback_batch_api_rejects_duplicate_file_paths(monkeypatch):
called = False
async def commit(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
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/feedback-batch",
json={
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
"files": [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "src/api.py", "content": "other\n", "expected_blob_sha": "blob2"},
],
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
calls = []
@@ -875,6 +1019,34 @@ def test_review_comments_are_bounded_and_require_a_file_and_body():
assert all(item["path"] and item["body"] for item in normalized)
def test_review_comments_expose_one_bounded_line_suggestion_only():
comments = [
{
"id": 1,
"path": "src/api.py",
"new_position": 4,
"body": "Handle the empty state:\n```suggestion\nreturn empty_result()\n```",
},
{
"id": 2,
"path": "src/api.py",
"new_position": 7,
"body": "Choose one:\n```suggestion\nfirst()\n```\n```suggestion\nsecond()\n```",
},
{
"id": 3,
"path": "src/api.py",
"body": "No stable line:\n```suggestion\nunsafe()\n```",
},
]
normalized = gitea_proxy._normalize_review_comments(comments)
assert normalized[0]["suggestion"] == {"line": 4, "content": "return empty_result()"}
assert "suggestion" not in normalized[1]
assert "suggestion" not in normalized[2]
def test_review_feedback_preserves_stable_review_and_comment_identities():
pull = {"requested_reviewers": []}
reviews = [{