Complete assigned pull requests from mobile My Work #162

Merged
timmy merged 1 commits from timmy/161-mobile-assigned-pr-completion into main 2026-08-07 03:31:11 +00:00
7 changed files with 648 additions and 5 deletions

View File

@ -16,10 +16,13 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, create issue comments, close assigned threads, create and self-assign issues, create issue comments, close assigned
issues, and submit pull-request reviews. Pull-request replies and mobile My Work issues, inspect/comment on assigned pull requests, merge assigned pull requests, and
issue comments use Gitea's issue-comment API; mobile issue capture requires issue submit pull-request reviews. Pull-request replies and mobile My Work issue and PR
creation and assignment permission, while closing an assigned issue and native comments use Gitea's issue-comment API; mobile issue capture requires issue
Comment, Approve, and Request changes reviews require repository write permission. creation and assignment permission. Closing an assigned issue, native Comment,
Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. 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

@ -139,6 +139,17 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-form label { display:grid; gap:6px; } .create-issue-form label { display:grid; gap:6px; }
.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; } .create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } .create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; }
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
@media (max-width: 600px) { @media (max-width: 600px) {
header { align-items:flex-start; } header { align-items:flex-start; }
.my-work { margin:0; } .my-work { margin:0; }
@ -149,6 +160,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; } .update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; } .issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.create-issue-panel { width:100%; border-left:0; padding:14px; } .create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
} }
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; } .obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; } .footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@ -365,6 +377,31 @@ textarea { resize: vertical; min-height: 120px; }
</section> </section>
</div> </div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</div>
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
<div class="row"><span class="pill" id="pull-ci-state">CI unknown</span><span class="pill" id="pull-merge-state">Checking merge status</span></div>
<p class="pull-sheet-content" id="pull-sheet-body"></p>
<h2>Changed files</h2><div id="pull-files"></div>
<h2>Recent discussion</h2><div id="pull-comments"></div>
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<div class="pull-sheet-actions">
<button id="merge-pull" type="button" disabled>Merge</button>
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
</div>
<div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title"> <div class="review-sheet" id="review-sheet" role="dialog" aria-modal="true" aria-labelledby="review-sheet-title">
<section class="review-sheet-panel"> <section class="review-sheet-panel">
<div class="review-sheet-header"> <div class="review-sheet-header">
@ -418,6 +455,7 @@ textarea { resize: vertical; min-height: 120px; }
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/issue-sheet.js"></script> <script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script> <script src="static/create-issue-sheet.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script> <script src="static/review-sheet.js"></script>
<script src="static/context-poller.js"></script> <script src="static/context-poller.js"></script>
<script> <script>
@ -465,6 +503,9 @@ textarea { resize: vertical; min-height: 120px; }
let updateTrigger = null; let updateTrigger = null;
let selectedIssue = null; let selectedIssue = null;
let issueTrigger = null; let issueTrigger = null;
let selectedPull = null;
let pullTrigger = null;
let selectedPullDetail = null;
let creatingIssue = false; let creatingIssue = false;
let progress = null; let progress = null;
let draft = null; let draft = null;
@ -483,6 +524,7 @@ textarea { resize: vertical; min-height: 120px; }
const reviewController = createReviewController({ fetchJson: fetchReviewJson }); const reviewController = createReviewController({ fetchJson: fetchReviewJson });
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage }); const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; } function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
function setClock() { qs('#clock').textContent = fmt(new Date()); } function setClock() { qs('#clock').textContent = fmt(new Date()); }
@ -713,6 +755,9 @@ textarea { resize: vertical; min-height: 120px; }
if (item.kind === 'issue') { if (item.kind === 'issue') {
return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>'; return '<article class="my-work-card"><button class="my-work-card-main issue-trigger" data-issue-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
} }
if (item.kind === 'pull') {
return '<article class="my-work-card"><button class="my-work-card-main pull-trigger" data-pull-index="' + index + '">' + contents + '</button>' + readUpdate + markRead + '</article>';
}
return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>'; return '<article class="my-work-card"><a class="my-work-card-main" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' + contents + '</a>' + readUpdate + markRead + '</article>';
}).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>'; }).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))) + '.</div>';
document.querySelectorAll('[data-review-index]').forEach(button => { document.querySelectorAll('[data-review-index]').forEach(button => {
@ -721,6 +766,9 @@ textarea { resize: vertical; min-height: 120px; }
document.querySelectorAll('[data-issue-index]').forEach(button => { document.querySelectorAll('[data-issue-index]').forEach(button => {
button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button)); button.addEventListener('click', () => openIssueSheet(lastMyWork[Number(button.dataset.issueIndex)], button));
}); });
document.querySelectorAll('[data-pull-index]').forEach(button => {
button.addEventListener('click', () => openPullSheet(lastMyWork[Number(button.dataset.pullIndex)], button));
});
document.querySelectorAll('[data-update-index]').forEach(button => { document.querySelectorAll('[data-update-index]').forEach(button => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
const item = lastMyWork[Number(button.dataset.updateIndex)]; const item = lastMyWork[Number(button.dataset.updateIndex)];
@ -846,6 +894,60 @@ textarea { resize: vertical; min-height: 120px; }
if (issueTrigger?.isConnected) issueTrigger.focus(); if (issueTrigger?.isConnected) issueTrigger.focus();
} }
async function openPullSheet(item, trigger) {
if (!item) return;
selectedPull = item;
pullTrigger = trigger;
selectedPullDetail = null;
qs('#pull-sheet').classList.add('open');
qs('#pull-sheet-key').textContent = item.key || '';
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
qs('#pull-sheet-status').textContent = 'Loading pull request…';
qs('#pull-sheet-body').textContent = '';
qs('#pull-files').textContent = '';
qs('#pull-comments').textContent = '';
qs('#pull-comment').value = pullController.loadDraft(item);
qs('#pull-comment-status').textContent = '';
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-merge-state').textContent = 'Checking merge status';
qs('#merge-pull').disabled = true;
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus();
try {
const detail = await pullController.load(item);
if (selectedPull !== item) return;
selectedPullDetail = detail;
const eligibility = createPullSheet.mergeEligibility(detail);
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
qs('#pull-sheet-body').textContent = detail.body || 'No description provided.';
qs('#pull-ci-state').textContent = 'CI ' + (detail.ci_state || 'unknown');
qs('#pull-merge-state').textContent = eligibility.reason;
qs('#merge-pull').disabled = !eligibility.allowed;
qs('#pull-files').innerHTML = (detail.files || []).length ? detail.files.map(file =>
'<div class="pull-file"><strong>' + escapeHtml(file.filename) + '</strong><div class="small">' +
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / ' + Number(file.deletions || 0) + '</div></div>'
).join('') : '<div class="muted">No changed files reported.</div>';
qs('#pull-comments').innerHTML = (detail.comments || []).length ? detail.comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#open-pull-gitea').href = detail.url || item.url || '#';
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
} catch (error) {
if (selectedPull !== item) return;
qs('#pull-sheet-status').textContent = error.message + ' Retry here or open it in Gitea.';
qs('#retry-pull-load').hidden = false;
qs('#retry-pull-load').focus();
}
}
function closePullSheet() {
qs('#pull-sheet').classList.remove('open');
selectedPull = null;
selectedPullDetail = null;
if (pullTrigger?.isConnected) pullTrigger.focus();
}
function saveIssueCaptureDraft() { function saveIssueCaptureDraft() {
issueCapture.saveDraft({ issueCapture.saveDraft({
repository: qs('#create-issue-repository').value, repository: qs('#create-issue-repository').value,
@ -1134,6 +1236,11 @@ textarea { resize: vertical; min-height: 120px; }
closeIssueSheet(); closeIssueSheet();
return; return;
} }
if (e.key === 'Escape' && selectedPull) {
e.preventDefault();
closePullSheet();
return;
}
if ((e.metaKey||e.ctrlKey) && e.key==='k') { if ((e.metaKey||e.ctrlKey) && e.key==='k') {
e.preventDefault(); e.preventDefault();
qs('#cmd-palette').classList.toggle('open'); qs('#cmd-palette').classList.toggle('open');
@ -1236,6 +1343,57 @@ textarea { resize: vertical; min-height: 120px; }
button.focus(); button.focus();
} }
}); });
qs('#close-pull-sheet').addEventListener('click', closePullSheet);
qs('#retry-pull-load').addEventListener('click', () => {
if (selectedPull) openPullSheet(selectedPull, pullTrigger);
});
qs('#pull-comment').addEventListener('input', event => {
if (selectedPull) pullController.saveDraft(selectedPull, event.target.value);
});
qs('#send-pull-comment').addEventListener('click', async () => {
if (!selectedPull) return;
const body = qs('#pull-comment').value.trim();
if (!body) {
qs('#pull-comment-status').textContent = 'Write a comment before posting.';
qs('#pull-comment').focus();
return;
}
const button = qs('#send-pull-comment');
button.disabled = true;
qs('#pull-comment-status').textContent = 'Posting comment…';
try {
const comment = await pullController.comment(selectedPull, body);
qs('#pull-comments .muted')?.remove();
qs('#pull-comments').insertAdjacentHTML('beforeend', '<div class="pull-comment-card">' + renderIssueComment(comment) + '</div>');
qs('#pull-comment').value = '';
qs('#pull-comment-status').textContent = 'Comment posted.';
} catch (error) {
qs('#pull-comment-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#pull-comment').focus();
} finally {
button.disabled = false;
}
});
qs('#merge-pull').addEventListener('click', async () => {
if (!selectedPull || !selectedPullDetail?.head_sha || !window.confirm('Merge ' + selectedPull.key + ' at current head?')) return;
const merging = selectedPull;
const button = qs('#merge-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
await pullController.merge(selectedPull, selectedPullDetail.head_sha);
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
closePullSheet();
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
button.disabled = false;
button.focus();
}
});
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true)); qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
qs('#retry-update-load').addEventListener('click', () => { qs('#retry-update-load').addEventListener('click', () => {
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork); if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);

63
frontend/pull-sheet.js Normal file
View File

@ -0,0 +1,63 @@
function mergeEligibility(detail) {
if (!detail || detail.state !== 'open' || detail.merged) {
return { allowed: false, reason: 'Pull request is not open' };
}
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
return { allowed: true, reason: 'Ready to merge' };
}
function createPullSheet({ fetchJson, storage }) {
let commentRequest = null;
let mergeRequest = null;
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
return {
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
},
saveDraft(item, body) {
try { storage?.setItem(draftKey(item), body); }
catch (_error) { /* The textarea remains the fallback. */ }
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
commentRequest = fetchJson(pathFor(item) + '/comments', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
}).then(result => {
try { storage?.removeItem(draftKey(item)); }
catch (_error) { /* The upstream comment is authoritative. */ }
return result;
}).finally(() => { commentRequest = null; });
return commentRequest;
},
merge(item, expectedHeadSha) {
if (mergeRequest) return mergeRequest;
mergeRequest = fetchJson(pathFor(item) + '/merge', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
},
};
}
createPullSheet.mergeEligibility = mergeEligibility;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;

View File

@ -18,6 +18,14 @@ 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 StalePullError(ValueError):
"""Raised before merge when an assigned pull request head changed."""
class PullNotMergeableError(ValueError):
"""Raised before merge when current pull state or checks prohibit it."""
def _auth() -> dict[str, str]: def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"} headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN: if GITEA_TOKEN:
@ -482,6 +490,94 @@ async def is_requested_review(repository: str, number: int) -> bool:
) )
async def is_assigned_pull(repository: str, number: int) -> bool:
pulls = await fetch(
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50"
)
return any(
isinstance(pull, dict)
and pull.get("number") == number
and isinstance(pull.get("repository"), dict)
and pull["repository"].get("full_name") == repository
for pull in (pulls or [])
)
async def pull_completion_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
sha = head.get("sha") if isinstance(head.get("sha"), str) else ""
files, status, comments = await asyncio.gather(
fetch(f"{base}/files"),
fetch(f"repos/{repository}/commits/{sha}/status"),
fetch(f"repos/{repository}/issues/{number}/comments?limit=20&page=1"),
)
user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return {
"repository": repository,
"number": number,
"title": pull.get("title") if isinstance(pull.get("title"), str) else "",
"body": pull.get("body") if isinstance(pull.get("body"), str) else "",
"url": _safe_web_url(pull.get("html_url")),
"author": user.get("login") if isinstance(user.get("login"), str) else "",
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"files": [
{
"filename": item.get("filename", ""),
"status": item.get("status") or "changed",
"additions": item.get("additions") or 0,
"deletions": item.get("deletions") or 0,
}
for item in (files if isinstance(files, list) else [])[:100]
if isinstance(item, dict) and isinstance(item.get("filename"), str)
],
"comments": [
_normalize_issue_comment(item)
for item in (comments if isinstance(comments, list) else [])[:20]
if isinstance(item, dict)
],
}
async def merge_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise PullNotMergeableError("Pull request state is unavailable")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
current_sha = head.get("sha")
if current_sha != expected_head_sha:
raise StalePullError("Pull request changed before merge")
status = await fetch(f"repos/{repository}/commits/{current_sha}/status")
ci_state = status.get("state") if isinstance(status, dict) else "unknown"
if (
pull.get("state") != "open"
or pull.get("draft") is True
or pull.get("mergeable") is not True
or pull.get("merged") is True
or ci_state != "success"
):
raise PullNotMergeableError("Pull request is not currently safe to merge")
response = await _get_client().post(
f"/api/v1/{base}/merge",
headers=_auth(),
json={"Do": "merge", "head_commit_id": current_sha},
)
response.raise_for_status()
return {"number": number, "merged": True, "state": "closed"}
async def pull_review_detail(repository: str, number: int) -> dict: async def pull_review_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/pulls/{number}" base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base) pull = await fetch(base)

View File

@ -137,6 +137,10 @@ class PullReviewSubmission(BaseModel):
return value return value
class PullMergeSubmission(BaseModel):
expected_head_sha: str = Field(min_length=1, max_length=128)
def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict: def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
user_model = User( user_model = User(
id=user_data["id"], id=user_data["id"],
@ -194,7 +198,7 @@ async def prevent_live_api_caching(request, call_next):
and request.url.path.endswith("/review") and request.url.path.endswith("/review")
) or request.url.path.startswith("/api/v1/notifications") or ( ) or request.url.path.startswith("/api/v1/notifications") or (
request.url.path.startswith("/api/v1/repos/") request.url.path.startswith("/api/v1/repos/")
and "/issues/" in request.url.path and ("/issues/" in request.url.path or "/pulls/" in request.url.path)
) or ( ) or (
request.url.path.startswith("/api/v1/repos/") request.url.path.startswith("/api/v1/repos/")
and request.url.path.endswith("/issues") and request.url.path.endswith("/issues")
@ -839,6 +843,101 @@ async def review_detail(owner: str, repo: str, number: int):
) )
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/detail")
async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
async def load_assigned_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.pull_completion_detail(repository, number)
try:
return await asyncio.wait_for(
load_assigned_pull(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except TimeoutError:
return JSONResponse(
{"error": "Loading the pull request timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The pull request is temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments", status_code=201)
async def comment_on_assigned_pull(
comment: IssueComment, owner: str, repo: str, number: int = PathParam(gt=0)
):
repository = f"{owner}/{repo}"
async def post_comment():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
result = await asyncio.wait_for(
post_comment(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The comment could not be posted. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, status_code=201)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge")
async def merge_assigned_pull(
submission: PullMergeSubmission,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
async def merge_pull():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await gitea_proxy.merge_assigned_pull(
repository, number, submission.expected_head_sha
)
try:
return await asyncio.wait_for(
merge_pull(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits were pushed. Refresh before merging."},
status_code=409,
)
except gitea_proxy.PullNotMergeableError:
return JSONResponse(
{"error": "This pull request is not currently safe to merge. Refresh its status."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201) @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/review", status_code=201)
async def submit_review( async def submit_review(
submission: PullReviewSubmission, owner: str, repo: str, number: int submission: PullReviewSubmission, owner: str, repo: str, number: int

View File

@ -11,6 +11,7 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js" REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js" ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js" CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity(): def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@ -691,6 +692,80 @@ Promise.all([first, duplicate]).then(results =>
} }
def test_pull_sheet_preserves_comment_draft_and_single_flights_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_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 calls = [];
let releaseComment;
const controller = createPullSheet({{
storage,
fetchJson: (url, options={{}}) => {{
calls.push({{url, method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/comments')) return new Promise(resolve => {{ releaseComment = () => resolve({{id:91, body:'Ship it'}}); }});
return Promise.resolve({{number:7, merged:true, state:'closed'}});
}},
}});
const item = {{repository:'stackchain/api', number:7}};
controller.saveDraft(item, 'Ship it');
const first = controller.comment(item, 'Ship it');
const duplicate = controller.comment(item, 'Ship it');
const during = controller.loadDraft(item);
releaseComment();
Promise.all([first, duplicate]).then(async comments => {{
const merges = await Promise.all([controller.merge(item, 'abc123'), controller.merge(item, 'abc123')]);
process.stdout.write(JSON.stringify({{calls, during, after:controller.loadDraft(item), comments, merges}}));
}});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output["during"] == "Ship it"
assert output["after"] == ""
assert output["calls"] == [
{"url": "api/v1/repos/stackchain/api/pulls/7/comments", "method": "POST", "body": {"body": "Ship it"}},
{"url": "api/v1/repos/stackchain/api/pulls/7/merge", "method": "POST", "body": {"expected_head_sha": "abc123"}},
]
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_enables_merge_only_for_current_safe_state():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const states = [
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:true, mergeable:true, merged:false, ci_state:'success', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:true, merged:false, ci_state:'failure', head_sha:'abc'}},
{{state:'open', draft:false, mergeable:false, merged:false, ci_state:'success', head_sha:'abc'}},
];
process.stdout.write(JSON.stringify(states.map(createPullSheet.mergeEligibility)));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
output = json.loads(result.stdout)
assert output[0] == {"allowed": True, "reason": "Ready to merge"}
assert output[1]["allowed"] is False and "draft" in output[1]["reason"].lower()
assert output[2]["allowed"] is False and "CI" in output[2]["reason"]
assert output[3]["allowed"] is False and "conflict" in output[3]["reason"].lower()
@pytest.mark.anyio
async def test_assigned_pulls_open_accessible_mobile_completion_sheet():
html = await dashboard()
assert 'id="pull-sheet"' in html and 'aria-modal="true"' in html
assert 'class="my-work-card-main pull-trigger"' in html
assert 'id="pull-sheet-status"' in html
assert 'id="pull-files"' in html and 'id="pull-comments"' in html
assert 'id="pull-comment"' in html and 'maxlength="10000"' in html
assert 'id="merge-pull"' in html and 'id="open-pull-gitea"' in html
assert '<script src="static/pull-sheet.js"></script>' in html
assert "pullController.load(item)" in html
assert "window.confirm('Merge ' + selectedPull.key" in html
assert "expected_head_sha" in html
assert "item.kind === 'pull'" in html and "pull-trigger" in html
@pytest.mark.anyio @pytest.mark.anyio
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations(): async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
html = await dashboard() html = await dashboard()

149
tests/test_pull_api.py Normal file
View File

@ -0,0 +1,149 @@
import httpx
import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def detail(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"body": "Ready to merge",
"url": "https://forge.example/stackchain/api/pulls/7",
"author": "alex",
"head_sha": "abc123",
"state": "open",
"draft": False,
"mergeable": True,
"merged": False,
"ci_state": "success",
"files": [{"filename": "src/api.py", "additions": 8, "deletions": 2}],
"comments": [{"id": 9, "author": "sam", "body": "Ship it"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/stackchain/api/pulls/7/detail")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["head_sha"] == "abc123"
assert response.json()["mergeable"] is True
@pytest.mark.anyio
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
async def assigned(repository, number):
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/repos/private/secret/pulls/9/detail")
assert response.status_code == 404
@pytest.mark.anyio
async def test_assigned_pull_comment_posts_only_after_assignment_check(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def comment(repository, number, body):
calls.append((repository, number, body))
return {"id": 91, "author": "timmy", "body": body}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
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/comments",
json={"body": " Ready to ship. "},
)
assert response.status_code == 201
assert calls == [("stackchain/api", 7, "Ready to ship.")]
@pytest.mark.anyio
async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
calls.append((repository, number, expected_head_sha))
return {"number": number, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, 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/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
async def assigned(repository, number):
return True
async def merge(*args):
raise gitea_proxy.StalePullError("changed")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge, 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/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 409
assert "New commits" in response.json()["error"]
@pytest.mark.anyio
async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "draft": False, "mergeable": True,
"merged": False, "head": {"sha": "abc123"},
})
return httpx.Response(200, json={"state": "failure"})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.PullNotMergeableError):
await gitea_proxy.merge_assigned_pull("stackchain/api", 7, "abc123")
finally:
await gitea_proxy.stop_client()
assert requests == [
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
]