Complete assigned pull requests from mobile My Work #162
11
README.md
11
README.md
|
|
@ -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
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, create issue comments, close assigned
|
||||
issues, and submit pull-request reviews. Pull-request replies and mobile My Work
|
||||
issue comments use Gitea's issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission, while closing an assigned issue and native
|
||||
Comment, Approve, and Request changes reviews require repository write permission.
|
||||
issues, inspect/comment on assigned pull requests, merge assigned pull requests, and
|
||||
submit pull-request reviews. Pull-request replies and mobile My Work issue and PR
|
||||
comments use Gitea's issue-comment API; mobile issue capture requires issue
|
||||
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
|
||||
only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Then start the API and bundled frontend:
|
||||
|
|
|
|||
|
|
@ -139,6 +139,17 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.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-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) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
|
|
@ -149,6 +160,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.update-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; }
|
||||
.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; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
|
@ -365,6 +377,31 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
</section>
|
||||
</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">
|
||||
<section class="review-sheet-panel">
|
||||
<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/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/context-poller.js"></script>
|
||||
<script>
|
||||
|
|
@ -465,6 +503,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let issueTrigger = null;
|
||||
let selectedPull = null;
|
||||
let pullTrigger = null;
|
||||
let selectedPullDetail = null;
|
||||
let creatingIssue = false;
|
||||
let progress = null;
|
||||
let draft = null;
|
||||
|
|
@ -483,6 +524,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
const reviewController = createReviewController({ fetchJson: fetchReviewJson });
|
||||
const issueController = createIssueSheet({ 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 setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
|
|
@ -713,6 +755,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
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>';
|
||||
}
|
||||
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>';
|
||||
}).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 => {
|
||||
|
|
@ -721,6 +766,9 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
document.querySelectorAll('[data-issue-index]').forEach(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 => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.updateIndex)];
|
||||
|
|
@ -846,6 +894,60 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
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() {
|
||||
issueCapture.saveDraft({
|
||||
repository: qs('#create-issue-repository').value,
|
||||
|
|
@ -1134,6 +1236,11 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
closeIssueSheet();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape' && selectedPull) {
|
||||
e.preventDefault();
|
||||
closePullSheet();
|
||||
return;
|
||||
}
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') {
|
||||
e.preventDefault();
|
||||
qs('#cmd-palette').classList.toggle('open');
|
||||
|
|
@ -1236,6 +1343,57 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
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('#retry-update-load').addEventListener('click', () => {
|
||||
if (selectedUpdate) notificationReader.open(selectedUpdate, lastMyWork);
|
||||
|
|
|
|||
63
frontend/pull-sheet.js
Normal file
63
frontend/pull-sheet.js
Normal 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;
|
||||
|
|
@ -18,6 +18,14 @@ class StaleReviewError(ValueError):
|
|||
"""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]:
|
||||
headers: dict[str, str] = {"Accept": "application/json"}
|
||||
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:
|
||||
base = f"repos/{repository}/pulls/{number}"
|
||||
pull = await fetch(base)
|
||||
|
|
|
|||
101
src/main.py
101
src/main.py
|
|
@ -137,6 +137,10 @@ class PullReviewSubmission(BaseModel):
|
|||
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:
|
||||
user_model = User(
|
||||
id=user_data["id"],
|
||||
|
|
@ -194,7 +198,7 @@ async def prevent_live_api_caching(request, call_next):
|
|||
and request.url.path.endswith("/review")
|
||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
||||
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 (
|
||||
request.url.path.startswith("/api/v1/repos/")
|
||||
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)
|
||||
async def submit_review(
|
||||
submission: PullReviewSubmission, owner: str, repo: str, number: int
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
|||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "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():
|
||||
|
|
@ -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
|
||||
async def test_assigned_issues_open_accessible_mobile_action_sheet_with_safe_mutations():
|
||||
html = await dashboard()
|
||||
|
|
|
|||
149
tests/test_pull_api.py
Normal file
149
tests/test_pull_api.py
Normal 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"),
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user