Compare commits

...
Author SHA1 Message Date
timmy fde3ba21ca Merge pull request 'Recover failed CI checks from the mobile pull workspace' (#1355) from timmy/1354-mobile-ci-recovery into main
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m55s
CI / release-candidate (push) Successful in 6s
2026-08-24 15:16:09 +00:00
timmy 8cb92c6858 feat: recover failed CI checks from mobile (Closes #1354)
CI / lint (pull_request) Successful in 3m30s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 5m44s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 15:06:01 +00:00
rockachopa 3e7a6c81fc Merge pull request 'feat: CI browser journey: Insights overlay does not inert page header at 320px' (#1353) from timmy/1352-ci-browser-journey-insights-overlay-does-not-ine into main
CI / lint (push) Successful in 3m31s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m32s
CI / release-candidate (push) Successful in 7s
2026-08-24 14:12:39 +00:00
timmy 084c0b5732 Fix mobile overlay inert ownership (#1352)
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m45s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:49:48 +00:00
rockachopa c73fbf8379 Merge pull request 'Load pull workspaces through one authorized snapshot' (#1351) from timmy/1350-authorized-pull-snapshot into main
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 5m27s
CI / release-candidate (push) Has been skipped
2026-08-24 13:24:47 +00:00
timmy b47ed5d86e perf: reuse authorized pull snapshots (Closes #1350)
CI / lint (pull_request) Successful in 3m31s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 13:14:48 +00:00
timmy f0f415c65d Merge pull request 'Complete authored pull request workspace access' (#1349) from timmy/1348-authored-pull-workspace into main
CI / lint (push) Successful in 3m9s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m26s
CI / release-candidate (push) Successful in 6s
2026-08-24 12:20:21 +00:00
timmy 2102b0e5da feat: complete authored pull workspace access (Closes #1348)
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 12:10:49 +00:00
rockachopa 050390d6f6 Merge pull request 'Surface authored pull requests in My Work' (#1347) from timmy/1346-authored-pull-queue into main
CI / lint (push) Successful in 3m18s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m16s
CI / release-candidate (push) Successful in 6s
2026-08-24 11:24:14 +00:00
14 changed files with 1067 additions and 67 deletions

No files matched your search

+8
View File
@@ -792,6 +792,14 @@ textarea { resize: vertical; min-height: 120px; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.ci-check-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; min-height:44px; }
.ci-check-inspect { min-height:44px; white-space:nowrap; }
.ci-check-recovery { max-width:100%; min-width:0; margin:0 8px 8px; padding:10px; border:1px solid #dc2626; border-radius:8px; background:#101b2e; }
.ci-check-recovery[hidden] { display:none; }
.ci-check-recovery h3 { margin:0 0 6px; overflow-wrap:anywhere; }
.ci-check-log { max-width:100%; max-height:42vh; margin:8px 0; padding:10px; overflow-x:auto; overflow-y:auto; border:1px solid #2a496e; border-radius:6px; background:#07101d; color:#dbeafe; font-size:.78rem; white-space:pre; }
.ci-check-recovery-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-top:6px; background:#101b2e; }
.ci-check-recovery-actions button { min-height:44px; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
+2
View File
@@ -368,6 +368,7 @@
let selectedPullDetail = null;
let pullConversation = null;
let pullReviewState = null;
let creatingIssue = false;
let createAndStartRequested = false;
let pendingIssueFilingIntent = 'create-and-assign';
@@ -4605,6 +4606,7 @@
async function openPullSheet(item, trigger, offlineDetail = null) {
if (!item) return;
if (!await ensurePullWorkflow(trigger)) return;
pullDetailPosition.open(workDetailIdentity('pull', item));
qs('#pull-review').inert = false;
qs('#pull-ownership').inert = false;
+9
View File
@@ -1756,6 +1756,15 @@
<summary><span>Checks</span><span class="small" id="pull-checks-summary">Not loaded</span></summary>
<button class="ci-check-refresh" id="refresh-pull-checks" type="button">Refresh checks</button>
<div class="ci-check-list" id="pull-check-list"></div>
<section class="ci-check-recovery" id="pull-check-recovery" aria-labelledby="pull-check-recovery-title" hidden>
<h3 id="pull-check-recovery-title" tabindex="-1">Failed check</h3>
<div id="pull-check-recovery-status" class="small" role="status" aria-live="assertive"></div>
<pre class="ci-check-log" id="pull-check-log" tabindex="0"></pre>
<div class="ci-check-recovery-actions">
<button id="retry-pull-check" type="button" disabled>Retry failed job</button>
<button id="close-pull-check-recovery" type="button">Close details</button>
</div>
</section>
</details>
<div class="pull-review-tools"><span id="pull-review-progress" class="small" aria-live="polite">Review progress unavailable.</span><button id="next-unreviewed-pull-file" type="button" disabled>Next unreviewed</button></div>
<div id="pull-files"></div>
+15 -1
View File
@@ -8,12 +8,25 @@
let active = false;
let previous = '#/my-work';
let retries = 0;
let backgroundInert = null;
function attr(element, name, value) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map(o.backgrounds.map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function render(open, focus = false) {
const wasActive = active;
active = Boolean(open && o.mediaQuery.matches);
@@ -28,7 +41,8 @@
attr(o.root, 'aria-modal', active ? 'true' : null);
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
o.root.inert = !active && o.mediaQuery.matches;
o.backgrounds.forEach(element => { element.inert = active; });
if (active) containBackground();
else releaseBackground();
o.dock.hidden = active;
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
if (active) o.closeButton.focus();
+134 -4
View File
@@ -187,17 +187,27 @@ function ownershipExitMessage(item, action, transitionResult) {
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function resetOwnershipControls(doc, item, checkpointed) {
function applyOwnershipCapabilities(doc, capabilities = null) {
const ownership = doc.querySelector('#pull-ownership');
const assigned = capabilities?.assigned !== false;
ownership.hidden = !assigned;
ownership.inert = !assigned;
return assigned;
}
function resetOwnershipControls(doc, item, checkpointed, capabilities = null) {
const qs = selector => doc.querySelector(selector);
const assigned = applyOwnershipCapabilities(doc, capabilities);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = false;
qs('#release-pull').disabled = false;
qs('#load-pull-handoff').disabled = !assigned;
qs('#release-pull').disabled = !assigned;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
qs('#pull-handoff-status').textContent = assigned ?
'Load teammates to transfer ownership.' : 'Assignment controls are not available for this pull request.';
qs('#edit-pull-content').hidden = true;
qs('#pull-edit-form').hidden = true;
qs('#pull-edit-status').textContent = '';
@@ -227,6 +237,7 @@ function bindReviewRequestControls(doc, controller, getSelected, getDetail, getL
load.dataset.reviewRequestBound = 'true';
controller.setReviewDetail = detail => {
resetReviewRequestControls(doc, detail);
applyOwnershipCapabilities(doc, detail?.capabilities);
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
const publishable = detail?.state === 'open' && detail?.draft === true &&
detail?.author === getLogin?.() && detail?.head_sha;
@@ -565,6 +576,7 @@ function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
bindReviewRequestControls(doc, controller, getSelected, getDetail, getLogin);
bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin);
bindCheckRecovery(doc, controller, getSelected, getDetail);
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
@@ -711,6 +723,21 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadCheckFailure(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/failure?expected_head_sha=' +
encodeURIComponent(expectedHeadSha), {
headers: { Accept: 'application/json' },
});
},
retryCheck(item, expectedHeadSha, runId, jobIndex) {
return fetchJson(pathFor(item) + '/checks/' + encodeURIComponent(runId) +
'/jobs/' + encodeURIComponent(jobIndex) + '/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
});
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
@@ -930,6 +957,108 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
};
}
function bindCheckRecovery(
doc, controller, selectedPull, selectedDetail,
confirmRetry = message => globalThis.confirm(message)
) {
let current = null;
const get = selector => doc.querySelector(selector);
function reset({ restoreFocus = true } = {}) {
const recovery = current;
current = null;
get('#pull-check-recovery').hidden = true;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = '';
get('#retry-pull-check').disabled = true;
if (restoreFocus) (recovery?.trigger?.isConnected ? recovery.trigger : get('#refresh-pull-checks'))?.focus();
}
async function poll(recovery, attempt = 0) {
if (current !== recovery || selectedPull() !== recovery.item) return;
if (attempt >= 12) {
get('#pull-check-recovery-status').textContent = 'Retry is still queued. Use Refresh checks to continue monitoring.';
return;
}
await new Promise(resolve => setTimeout(resolve, 1500));
if (current !== recovery || selectedPull() !== recovery.item) return;
try {
const status = await controller.loadChecks(recovery.item);
if (current !== recovery || selectedPull() !== recovery.item) return;
if (status.head_sha !== recovery.headSha) {
get('#pull-check-recovery-status').textContent = 'New commits arrived. Reload the pull request before retrying again.';
return;
}
const check = (status.checks || []).find(candidate =>
candidate?.recovery?.run_id === recovery.runId && candidate?.recovery?.job_index === recovery.jobIndex
);
if (!check || !['failure', 'error', 'pending'].includes(check.state)) {
get('#refresh-pull-checks').click();
get('#pull-check-recovery-status').textContent = 'Retried check completed. Merge readiness refreshed.';
return;
}
get('#pull-check-recovery-status').textContent = check.state === 'pending'
? 'Retried check is running…' : 'Retry queued; waiting for a fresh run…';
} catch (_error) {
get('#pull-check-recovery-status').textContent = 'Waiting for check status…';
}
return poll(recovery, attempt + 1);
}
get('#pull-check-list').addEventListener('click', async event => {
const trigger = event.target.closest('.ci-check-inspect');
const item = selectedPull();
const detail = selectedDetail();
if (!trigger || !item || !detail?.head_sha) return;
const runId = Number(trigger.dataset.runId);
const jobIndex = Number(trigger.dataset.jobIndex);
if (!Number.isInteger(runId) || !Number.isInteger(jobIndex)) return;
const recovery = {
item, headSha: detail.head_sha, runId, jobIndex, trigger,
name: trigger.closest('.ci-check')?.querySelector('strong')?.textContent || 'Failed check',
};
current = recovery;
get('#pull-check-recovery').hidden = false;
get('#pull-check-recovery-title').textContent = recovery.name;
get('#pull-check-log').textContent = 'Loading failure details…';
get('#pull-check-recovery-status').textContent = 'Loading failure details…';
get('#retry-pull-check').disabled = true;
get('#pull-check-recovery-title').focus();
try {
const result = await controller.loadCheckFailure(item, recovery.headSha, runId, jobIndex);
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = result.excerpt || 'No failure output was reported.';
get('#pull-check-recovery-status').textContent = 'Failure details loaded.';
get('#retry-pull-check').disabled = false;
} catch (error) {
if (current !== recovery || selectedPull() !== item) return;
get('#pull-check-log').textContent = '';
get('#pull-check-recovery-status').textContent = error.message;
}
});
get('#close-pull-check-recovery').addEventListener('click', () => reset());
get('#pull-checks').addEventListener('toggle', () => {
if (current && selectedPull() !== current.item) reset({ restoreFocus: false });
});
get('#retry-pull-check').addEventListener('click', async () => {
const recovery = current;
if (!recovery || selectedPull() !== recovery.item) return;
if (!confirmRetry('Retry ' + recovery.name + ' for head ' + recovery.headSha.slice(0, 12) + '?')) return;
const button = get('#retry-pull-check');
button.disabled = true;
get('#pull-check-recovery-status').textContent = 'Queueing retry…';
try {
await controller.retryCheck(recovery.item, recovery.headSha, recovery.runId, recovery.jobIndex);
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = 'Retry queued; waiting for a fresh run…';
poll(recovery);
} catch (error) {
if (current !== recovery) return;
get('#pull-check-recovery-status').textContent = error.message;
button.disabled = false;
button.focus();
}
});
return { reset };
}
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.review = reviewEligibility;
createPullSheet.renderReviewerStatuses = renderReviewerStatuses;
@@ -946,4 +1075,5 @@ createPullSheet.bindContextEditor = bindContextEditor;
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
createPullSheet.bindFeedbackControls = bindFeedbackControls;
createPullSheet.bindCheckRecovery = bindCheckRecovery;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+11 -1
View File
@@ -384,10 +384,20 @@ function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
const recovery = check.recovery;
const inspect = !offline && ['failure', 'error'].includes(check.state) &&
Number.isInteger(recovery?.run_id) && recovery.run_id > 0 &&
Number.isInteger(recovery?.job_index) && recovery.job_index >= 0
? '<button type="button" class="ci-check-inspect" data-run-id="' + recovery.run_id +
'" data-job-index="' + recovery.job_index + '">Inspect failure</button>'
: '';
const actions = inspect || link
? '<div class="ci-check-actions">' + inspect + link + '</div>'
: '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + actions + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
+177 -13
View File
@@ -793,13 +793,25 @@ def _normalize_commit_checks(status: Any) -> list[dict]:
if state not in rank:
state = "unknown"
description = entry.get("description")
checks.append({
url = _safe_gitea_web_url(entry.get("target_url"))
check = {
"name": name.strip()[:120],
"state": state,
"description": description.strip()[:240] if isinstance(description, str) else "",
"url": _safe_gitea_web_url(entry.get("target_url")),
"url": url,
"_index": index,
})
}
configured_path = urlsplit(GITEA_URL).path.rstrip("/")
action_match = re.fullmatch(
re.escape(configured_path) + r"/[^/]+/[^/]+/actions/runs/(\d+)/jobs/(\d+)",
urlsplit(url).path,
)
if action_match:
check["recovery"] = {
"run_id": int(action_match.group(1)),
"job_index": int(action_match.group(2)),
}
checks.append(check)
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
@@ -1984,6 +1996,14 @@ async def pull_review_candidates(repository: str, number: int) -> list[dict]:
][:25]
def _login_can_manage_pull(login: str, pull: dict) -> bool:
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
return (
author.get("login", "").casefold() == login.casefold()
or _login_in_users(login, pull.get("assignees"))
)
async def request_assigned_pull_review(
repository: str, number: int, reviewer: str, expected_head_sha: str
) -> dict:
@@ -1994,7 +2014,7 @@ async def request_assigned_pull_review(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or not _login_can_manage_pull(login, pull)
or head.get("sha") != expected_head_sha
):
raise IssueNotAvailableError("Pull request is no longer eligible for review request")
@@ -2050,7 +2070,6 @@ async def publish_authored_assigned_pull(
or pull.get("merged") is True
or pull.get("draft") is not True
or author.get("login", "").casefold() != login.casefold()
or not _login_in_users(login, pull.get("assignees"))
or head.get("sha") != expected_head_sha
or not ready_title
or ready_title == title
@@ -2106,7 +2125,7 @@ async def cancel_assigned_pull_review(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not _login_in_users(login, pull.get("assignees"))
or not _login_can_manage_pull(login, pull)
or head.get("sha") != expected_head_sha
or reviewer not in requested
):
@@ -2711,7 +2730,7 @@ async def resolve_work_route(
and (
(kind == "issue" and not isinstance(target.get("pull_request"), dict) and assigned)
or (kind == "filed" and not isinstance(target.get("pull_request"), dict) and authored)
or (kind == "pull" and assigned)
or (kind == "pull" and (assigned or authored))
or (kind == "review" and requested)
)
)
@@ -2730,6 +2749,14 @@ async def resolve_work_route(
"is_assigned": assigned,
"work_reasons": ["created_by_me"],
} if kind == "filed" else {}),
**({
"is_assigned": assigned,
"work_reasons": [
reason for reason, present in (
("assigned_to_me", assigned), ("authored_by_me", authored)
) if present
],
} if kind == "pull" else {}),
}
@@ -2809,6 +2836,34 @@ async def is_requested_review(repository: str, number: int) -> bool:
)
async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
return {
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
}
async def pull_workspace_snapshot(repository: str, number: int) -> dict:
"""Resolve one canonical pull and the access granted by that exact snapshot."""
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
open_pull = pull.get("state") == "open" and pull.get("merged") is not True
return {
"pull": pull,
"capabilities": {
"authored": open_pull and author.get("login", "").casefold() == login.casefold(),
"assigned": open_pull and _login_in_users(login, pull.get("assignees")),
},
}
async def is_assigned_pull(repository: str, number: int) -> bool:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
@@ -2833,8 +2888,7 @@ async def update_authored_assigned_pull(
if (
pull.get("state") != "open"
or pull.get("merged") is True
or author.get("login") != login
or not _login_in_users(login, pull.get("assignees"))
or author.get("login", "").casefold() != login.casefold()
):
raise IssueNotAvailableError("pull request not found")
if head.get("sha") != expected_head_sha:
@@ -2865,9 +2919,12 @@ async def update_authored_assigned_pull(
}
async def pull_completion_detail(repository: str, number: int) -> dict:
async def pull_completion_detail(
repository: str, number: int, pull: dict | None = None
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if pull is None:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
conversation = await issue_conversation_page(repository, number)
@@ -2987,9 +3044,12 @@ def _normalize_reviewer_statuses(pull: dict, reviews: object, head_sha: str) ->
return sorted(statuses.values(), key=lambda item: item["login"].casefold())
async def pull_completion_review(repository: str, number: int) -> dict:
async def pull_completion_review(
repository: str, number: int, pull: dict | None = None
) -> dict:
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if pull is None:
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
@@ -3144,6 +3204,110 @@ async def pull_check_status(repository: str, number: int) -> dict:
}
def _bounded_action_log_excerpt(value: str, max_bytes: int = 24 * 1024) -> str:
redacted = re.sub(
r"(?im)^(authorization\s*:)\s*[^\r\n]*$",
r"\1 [redacted]",
value,
)
redacted = re.sub(
r"(?im)\b(gitea_token|github_token|access_token)=\S+",
r"\1=[redacted]",
redacted,
)
encoded = redacted.encode("utf-8")
if len(encoded) <= max_bytes:
return redacted
return encoded[-max_bytes:].decode("utf-8", errors="ignore")
async def action_failure_excerpt(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
response = await _get_client().get(
f"{GITEA_URL}/{quote(repository, safe='/')}/actions/runs/{run_id}/jobs/{job_index}/logs",
headers=_auth(),
)
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"name": matching["name"],
"excerpt": _bounded_action_log_excerpt(response.text),
}
async def retry_action_job(
repository: str,
number: int,
expected_head_sha: str,
run_id: int,
job_index: int,
) -> dict:
pull = await fetch(f"repos/{repository}/pulls/{number}")
head = pull.get("head") if isinstance(pull, dict) else None
sha = head.get("sha") if isinstance(head, dict) else None
if sha != expected_head_sha:
raise StalePullError("Pull request head changed")
status = await fetch(f"repos/{repository}/commits/{sha}/status")
matching = next(
(
check
for check in _normalize_commit_checks(status)
if check.get("state") in {"failure", "error"}
and check.get("recovery") == {"run_id": run_id, "job_index": job_index}
),
None,
)
if matching is None:
raise ValueError("Failed Gitea Actions job is unavailable")
job_url = (
f"{GITEA_URL}/{quote(repository, safe='/')}"
f"/actions/runs/{run_id}/jobs/{job_index}"
)
page = await _get_client().get(job_url, headers=_auth())
page.raise_for_status()
csrf_match = re.search(r"\bcsrfToken:\s*'([^']+)'", page.text)
if not csrf_match:
raise ValueError("Gitea did not provide a retry authorization token")
csrf_token = csrf_match.group(1)
response = await _get_client().post(
f"{job_url}/rerun",
headers={**_auth(), "X-Csrf-Token": csrf_token},
data={"_csrf": csrf_token},
)
if response.is_error:
response.raise_for_status()
return {
"head_sha": sha,
"run_id": run_id,
"job_index": job_index,
"status": "queued",
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
+160 -24
View File
@@ -6173,8 +6173,10 @@ async def edit_assigned_pull_comment(
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
@@ -6190,8 +6192,10 @@ async def delete_assigned_pull_comment(
),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _delete_conversation_comment(
request, step_up_grant, repository, number, comment_id
)
@@ -6555,14 +6559,31 @@ async def requested_review_checks(
)
async def _pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
# Preserve the established fast path for assigned work while widening access
# only after Gitea confirms authorship of the same open pull request.
if await gitea_proxy.is_assigned_pull(repository, number):
return {"authored": False, "assigned": True}
return await gitea_proxy.pull_workspace_capabilities(repository, number)
def _has_pull_workspace_access(capabilities: dict[str, bool]) -> bool:
return capabilities.get("authored") is True or capabilities.get("assigned") is True
@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)
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
capabilities = snapshot["capabilities"]
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
detail = await gitea_proxy.pull_completion_detail(
repository, number, snapshot["pull"]
)
return {**detail, "capabilities": capabilities}
try:
return await asyncio.wait_for(
@@ -6655,9 +6676,14 @@ async def assigned_pull_review_data(
repository = f"{owner}/{repo}"
async def load_assigned_pull_review():
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_review(repository, number)
snapshot = await gitea_proxy.pull_workspace_snapshot(repository, number)
capabilities = snapshot["capabilities"]
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
review = await gitea_proxy.pull_completion_review(
repository, number, snapshot["pull"]
)
return {**review, "capabilities": capabilities}
try:
return await asyncio.wait_for(
@@ -6690,8 +6716,10 @@ async def assigned_pull_review_feedback(
repository = f"{owner}/{repo}"
async def load_feedback():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.pull_review_feedback(
repository, number, review_id, expected_head_sha
)
@@ -6730,8 +6758,10 @@ async def assigned_pull_checks(
repository = f"{owner}/{repo}"
async def load_checks():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.pull_check_status(repository, number)
try:
@@ -6754,6 +6784,106 @@ async def assigned_pull_checks(
)
@app.get(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/failure"
)
async def pull_action_failure(
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
expected_head_sha: str = Query(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_excerpt():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.action_failure_excerpt(
repository, number, expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
load_excerpt(), timeout=REVIEW_DETAIL_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before diagnosing this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This failed check does not expose a recoverable Actions job."},
status_code=404,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failure log is temporarily unavailable. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post(
"/api/v1/repos/{owner}/{repo}/pulls/{number}/checks/{run_id}/jobs/{job_index}/retry"
)
async def retry_pull_action_job(
retry: PullReadyRequest,
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def retry_job():
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.retry_action_job(
repository, number, retry.expected_head_sha, run_id, job_index
)
try:
result = await asyncio.wait_for(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except gitea_proxy.StalePullError:
return JSONResponse(
{"error": "New commits arrived. Reload checks before retrying this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
return JSONResponse(
{"error": "This check is no longer failed or cannot be retried."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except Exception:
return JSONResponse(
{"error": "The failed job could not be queued. Open the job or retry."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
async def pull_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
@@ -6811,8 +6941,10 @@ async def pull_review_candidates(
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
result = await asyncio.wait_for(
gitea_proxy.pull_review_candidates(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
@@ -6915,8 +7047,10 @@ async def assigned_pull_conversation(
repository = f"{owner}/{repo}"
async def load_conversation():
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
try:
@@ -6944,8 +7078,10 @@ async def comment_on_assigned_pull(
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")
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
try:
@@ -6986,8 +7122,8 @@ async def merge_assigned_pull(
)
try:
assigned = await asyncio.wait_for(
gitea_proxy.is_assigned_pull(repository, number),
capabilities = await asyncio.wait_for(
_pull_workspace_capabilities(repository, number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
@@ -6996,8 +7132,8 @@ async def merge_assigned_pull(
status_code=503,
headers={"Retry-After": "1"},
)
if not assigned:
raise HTTPException(status_code=404, detail="Assigned pull request not found")
if not _has_pull_workspace_access(capabilities):
raise HTTPException(status_code=404, detail="Pull request not found")
journal = _security_event_store()
try:
@@ -26,6 +26,7 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
page.add_script_tag(path=FRONTEND / "my-work.js")
page.add_script_tag(path=FRONTEND / "work-route.js")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.add_script_tag(path=FRONTEND / "pull-sheet.js")
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
row = page.locator('[data-mobile-queue="authored"]')
@@ -63,6 +64,25 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
"opened": "opened",
"count": 1,
}
capabilities = page.evaluate("""() => {
const item = {key:'stackchain/dashboard#41'};
createPullSheet.resetOwnershipControls(
document, item, () => false, {authored:true, assigned:false}
);
const ownership = document.querySelector('#pull-ownership');
return {
hidden: ownership.hidden,
inert: ownership.inert,
releaseDisabled: document.querySelector('#release-pull').disabled,
handoffDisabled: document.querySelector('#load-pull-handoff').disabled,
};
}""")
assert capabilities == {
"hidden": True,
"inert": True,
"releaseDisabled": True,
"handoffDisabled": True,
}
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
+31
View File
@@ -133,6 +133,37 @@ process.stdout.write(JSON.stringify({{current:controller.current(),hash:location
}
def test_late_inactive_route_event_does_not_release_background_owned_by_next_overlay():
script = f"""
const createInsights = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = () => ({{
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
removeAttribute(name) {{ delete this.attributes[name]; }},
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
focus() {{ this.focuses += 1; }},
}});
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), header=element();
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
const history={{state:null, pushState() {{}}, back() {{}}, replaceState(state,unused,url) {{
this.state=state; location.hash=url.slice(url.indexOf('#'));
}}}};
const controller=createInsights({{
root,launcher,closeButton,dock,hud,backgrounds:[header],history,location,
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
mediaQuery:{{matches:true,addEventListener() {{}}}},
}});
controller.start();
closeButton.listeners.click();
header.inert = true;
listeners.hashchange();
process.stdout.write(JSON.stringify({{current:controller.current(), headerInert:header.inert}}));
"""
assert run_node(script) == {"current": False, "headerInert": True}
def test_escape_requests_back_navigation_only_while_insights_is_open():
script = f"""
const createInsights=require({json.dumps(str(CONTROLLER))});
+103
View File
@@ -7642,6 +7642,39 @@ process.stdout.write(JSON.stringify({{
assert output["released"] == "stackchain/api#7 released. Choose the next ready Today item."
def test_authored_unassigned_pull_hides_assignment_ownership_controls():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const elements = new Map([
['#pull-ownership', {{open:true,hidden:false,inert:false}}],
['#pull-handoff-recipient', {{innerHTML:'stale',disabled:false}}],
['#confirm-pull-handoff', {{disabled:false,textContent:''}}],
['#load-pull-handoff', {{disabled:false}}],
['#release-pull', {{disabled:false,textContent:''}}],
['#pull-handoff-status', {{textContent:''}}],
['#edit-pull-content', {{hidden:false}}],
['#pull-edit-form', {{hidden:false}}],
['#pull-edit-status', {{textContent:'stale'}}],
]);
const doc = {{querySelector:selector => elements.get(selector)}};
createPullSheet.resetOwnershipControls(
doc,
{{key:'stackchain/api#7'}},
() => false,
{{authored:true,assigned:false}}
);
process.stdout.write(JSON.stringify(Object.fromEntries(elements)));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["#pull-ownership"]["hidden"] is True
assert output["#pull-ownership"]["inert"] is True
assert output["#load-pull-handoff"]["disabled"] is True
assert output["#release-pull"]["disabled"] is True
def test_pull_sheet_single_flights_handoff_and_release_ownership_mutations():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -8670,6 +8703,76 @@ process.stdout.write(JSON.stringify({{result, offline}}));
assert output["offline"]["summary"].startswith("Last known · ")
def test_failed_actions_check_renders_in_app_recovery_control_only_when_supported():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const escapeHtml = value => String(value)
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const supported = reviewSheet.renderChecks([
{{name:'CI / lint', state:'failure', url:'https://forge.example/jobs/3', recovery:{{run_id:91, job_index:3}}}},
], escapeHtml);
const external = reviewSheet.renderChecks([
{{name:'Vendor scan', state:'failure', url:'https://forge.example/jobs/4'}},
], escapeHtml);
process.stdout.write(JSON.stringify({{supported, external}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert 'class="ci-check-inspect"' in output["supported"]["html"]
assert 'data-run-id="91"' in output["supported"]["html"]
assert 'data-job-index="3"' in output["supported"]["html"]
assert "Inspect failure" in output["supported"]["html"]
assert "ci-check-inspect" not in output["external"]["html"]
assert "Open job" in output["external"]["html"]
def test_pull_controller_loads_failure_and_retries_exact_head():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const calls = [];
const controller = createPullSheet({{
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{status:'queued'}}; }},
storage: null,
}});
const item = {{repository:'stackchain/api', number:7}};
controller.loadCheckFailure(item, 'abc1234', 91, 3).then(() =>
controller.retryCheck(item, 'abc1234', 91, 3)
).then(() => process.stdout.write(JSON.stringify(calls)));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
calls = json.loads(result.stdout)
assert calls[0]["url"].endswith(
"/pulls/7/checks/91/jobs/3/failure?expected_head_sha=abc1234"
)
assert calls[0]["options"]["headers"]["Accept"] == "application/json"
assert calls[1]["url"].endswith("/pulls/7/checks/91/jobs/3/retry")
assert calls[1]["options"]["method"] == "POST"
assert json.loads(calls[1]["options"]["body"]) == {"expected_head_sha": "abc1234"}
@pytest.mark.anyio
async def test_mobile_pull_workspace_packages_accessible_ci_recovery_panel():
html = await dashboard()
assert 'id="pull-check-recovery"' in html
assert 'id="pull-check-recovery-title"' in html
assert 'id="pull-check-log"' in html
assert 'id="retry-pull-check"' in html
assert 'id="close-pull-check-recovery"' in html
assert 'aria-live="assertive"' in html
assert "createPullSheet.bindOwnershipControls" in html
assert "#pull-check-list" in html and "ci-check-inspect" in html
assert ".ci-check-recovery" in html and "max-width:100%" in html
assert ".ci-check-log" in html and "overflow-x:auto" in html
assert ".ci-check-actions" in html and "min-height:44px" in html
@pytest.mark.anyio
async def test_mobile_pull_review_sheets_refresh_checks_without_reloading_diffs():
html = await dashboard()
+366 -24
View File
@@ -39,7 +39,7 @@ async def test_pull_author_can_publish_assigned_draft_at_expected_head(monkeypat
@pytest.mark.anyio
async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_head():
async def test_gitea_publishes_authored_unassigned_draft_and_verifies_ready_head():
requests = []
pull = {
"number": 7,
@@ -48,7 +48,7 @@ async def test_gitea_publishes_only_authored_assigned_draft_and_verifies_ready_h
"draft": True,
"merged": False,
"user": {"login": "alex"},
"assignees": [{"login": "alex"}],
"assignees": [],
"head": {"sha": "abc1234"},
}
@@ -131,7 +131,7 @@ async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
@pytest.mark.anyio
async def test_gitea_pull_context_update_requires_author_assignment_and_matching_head():
async def test_gitea_pull_context_update_allows_author_without_assignment_at_matching_head():
requests = []
async def handler(request):
@@ -141,7 +141,7 @@ async def test_gitea_pull_context_update_requires_author_assignment_and_matching
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "GET":
return httpx.Response(200, json={
"number": 7, "title": "Old context", "body": "Old body", "state": "open",
"merged": False, "user": {"login": "alex"}, "assignees": [{"login": "alex"}],
"merged": False, "user": {"login": "alex"}, "assignees": [],
"head": {"sha": "abc1234"},
"html_url": "https://forge.example/stackchain/api/pulls/7",
})
@@ -173,12 +173,116 @@ async def test_gitea_pull_context_update_requires_author_assignment_and_matching
@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 test_gitea_authored_unassigned_pull_has_workspace_access():
requests = []
async def detail(repository, number):
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={
"number": 7,
"state": "open",
"merged": False,
"user": {"login": "alex"},
"assignees": [],
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
capabilities = await gitea_proxy.pull_workspace_capabilities("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert capabilities == {"authored": True, "assigned": False}
assert requests == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
]
@pytest.mark.anyio
async def test_authored_pull_detail_reuses_one_authorized_snapshot():
requests = []
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={
"number": 7,
"title": "Ship mobile flow",
"body": "Ready for review",
"state": "open",
"merged": False,
"head": {"sha": "abc1234"},
"user": {"login": "alex"},
"assignees": [],
})
if request.url.path == "/api/v1/repos/stackchain/api/issues/7/comments":
return httpx.Response(200, json=[], headers={"X-Total-Count": "0"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
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")
finally:
await gitea_proxy.stop_client()
assert response.status_code == 200
assert response.json()["capabilities"] == {"authored": True, "assigned": False}
assert requests.count(("GET", "/api/v1/user")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/pulls/7")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/issues/7/comments")) == 1
@pytest.mark.anyio
async def test_authored_unassigned_pull_detail_returns_capabilities(monkeypatch):
pull = {"number": 7}
async def snapshot(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {"pull": pull, "capabilities": {"authored": True, "assigned": False}}
async def detail(repository, number, authorized_pull):
assert authorized_pull is pull
return {
"repository": repository,
"number": number,
"title": "Ship mobile flow",
"body": "Ready for review",
"author": "alex",
"head_sha": "abc1234",
"state": "open",
"conversation": {"comments": [], "page": 1, "older_page": None, "total": 0},
}
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_detail", detail)
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.json()["capabilities"] == {"authored": True, "assigned": False}
@pytest.mark.anyio
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
pull = {"number": 7}
async def snapshot(repository, number):
assert (repository, number) == ("stackchain/api", 7)
return {"pull": pull, "capabilities": {"authored": False, "assigned": True}}
async def detail(repository, number, authorized_pull):
assert (repository, number) == ("stackchain/api", 7)
assert authorized_pull is pull
return {
"repository": repository,
"number": number,
@@ -196,7 +300,7 @@ async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
"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_workspace_snapshot", snapshot)
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:
@@ -210,10 +314,10 @@ async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
@pytest.mark.anyio
async def test_assigned_pull_detail_rejects_unassigned_pull(monkeypatch):
async def assigned(repository, number):
return False
async def snapshot(repository, number):
return {"pull": {}, "capabilities": {"authored": False, "assigned": False}}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned, raising=False)
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
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")
@@ -263,12 +367,14 @@ async def test_gitea_assigned_pull_detail_loads_reading_without_review_resources
@pytest.mark.anyio
async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monkeypatch):
calls = []
pull = {"number": 7}
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def snapshot(repository, number):
calls.append(("snapshot", repository, number))
return {"pull": pull, "capabilities": {"authored": False, "assigned": True}}
async def review(repository, number):
async def review(repository, number, authorized_pull):
assert authorized_pull is pull
calls.append(("review", repository, number))
return {
"repository": repository, "number": number, "head_sha": "abc123",
@@ -276,7 +382,7 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
"ci_state": "success", "files": [{"filename": "src/api.py"}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_workspace_snapshot", snapshot)
monkeypatch.setattr(main.gitea_proxy, "pull_completion_review", review, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -286,11 +392,49 @@ async def test_assigned_pull_review_endpoint_loads_review_payload_on_demand(monk
assert response.headers["cache-control"] == "no-store"
assert response.json()["files"] == [{"filename": "src/api.py"}]
assert calls == [
("assigned", "stackchain/api", 7),
("snapshot", "stackchain/api", 7),
("review", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_assigned_pull_review_reuses_one_authorized_snapshot():
requests = []
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={
"number": 7, "state": "open", "merged": False, "mergeable": True,
"head": {"sha": "abc1234"}, "user": {"login": "sam"},
"assignees": [{"login": "alex"}], "requested_reviewers": [],
})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7/files":
return httpx.Response(200, json=[])
if request.url.path == "/api/v1/repos/stackchain/api/commits/abc1234/status":
return httpx.Response(200, json={"state": "pending", "statuses": []})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7.diff":
return httpx.Response(200, text="")
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7/reviews":
return httpx.Response(200, json=[])
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
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/review-data")
finally:
await gitea_proxy.stop_client()
assert response.status_code == 200
assert response.json()["capabilities"] == {"authored": False, "assigned": True}
assert requests.count(("GET", "/api/v1/user")) == 1
assert requests.count(("GET", "/api/v1/repos/stackchain/api/pulls/7")) == 1
@pytest.mark.anyio
async def test_assigned_pull_feedback_endpoint_authorizes_and_returns_one_review(monkeypatch):
calls = []
@@ -506,6 +650,201 @@ async def test_assigned_pull_checks_endpoint_authorizes_and_returns_status_only(
]
def test_commit_checks_expose_only_same_forge_actions_recovery_metadata(monkeypatch):
monkeypatch.setattr(
gitea_proxy, "GITEA_URL", "https://forge.example/git"
)
checks = gitea_proxy._normalize_commit_checks({
"statuses": [
{
"context": "CI / lint (pull_request)",
"status": "failure",
"description": "Failed",
"target_url": "/git/acme/mobile/actions/runs/91/jobs/3",
},
{
"context": "external",
"status": "failure",
"target_url": "https://checks.example/jobs/4",
},
]
})
assert checks[0] == {
"name": "CI / lint (pull_request)",
"state": "failure",
"description": "Failed",
"url": "https://forge.example/git/acme/mobile/actions/runs/91/jobs/3",
"recovery": {"run_id": 91, "job_index": 3},
}
assert checks[1]["url"] == ""
assert "recovery" not in checks[1]
@pytest.mark.anyio
async def test_action_failure_excerpt_is_bounded_and_redacts_credentials(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
noisy_log = "old output\n" * 4_000 + (
"Authorization: token super-secret\n"
"GITEA_TOKEN=another-secret\n"
"AssertionError: expected ready, got blocked\n"
)
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
if request.url.path.endswith("/commits/abc1234/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / lint (pull_request)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.url.path.endswith("/actions/runs/91/jobs/3/logs"):
return httpx.Response(200, text=noisy_log)
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.action_failure_excerpt(
"stackchain/api", 7, "abc1234", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result["head_sha"] == "abc1234"
assert result["run_id"] == 91
assert result["job_index"] == 3
assert len(result["excerpt"].encode()) <= 24 * 1024
assert "AssertionError: expected ready, got blocked" in result["excerpt"]
assert "super-secret" not in result["excerpt"]
assert "another-secret" not in result["excerpt"]
assert "Authorization: [redacted]" in result["excerpt"]
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status"),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3/logs"),
]
@pytest.mark.anyio
async def test_action_failure_endpoint_authorizes_and_returns_no_store(monkeypatch):
calls = []
async def capabilities(repository, number):
calls.append(("access", repository, number))
return {"authored": True, "assigned": False}
async def excerpt(repository, number, head_sha, run_id, job_index):
calls.append(("excerpt", repository, number, head_sha, run_id, job_index))
return {
"head_sha": head_sha,
"run_id": run_id,
"job_index": job_index,
"name": "CI / lint",
"excerpt": "AssertionError: failed",
}
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "action_failure_excerpt", excerpt, 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/checks/91/jobs/3/failure",
params={"expected_head_sha": "abc1234"},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json()["excerpt"] == "AssertionError: failed"
assert calls == [
("access", "stackchain/api", 7),
("excerpt", "stackchain/api", 7, "abc1234", 91, 3),
]
@pytest.mark.anyio
async def test_retry_failed_action_job_validates_head_and_uses_gitea_csrf(monkeypatch):
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "http://test/git")
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.headers.get("x-csrf-token")))
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={"head": {"sha": "abc1234"}})
if request.url.path.endswith("/commits/abc1234/status"):
return httpx.Response(200, json={"statuses": [{
"context": "CI / lint (pull_request)",
"status": "failure",
"target_url": "/git/stackchain/api/actions/runs/91/jobs/3",
}]})
if request.method == "GET" and request.url.path.endswith("/actions/runs/91/jobs/3"):
return httpx.Response(200, text="<script>csrfToken: 'csrf-123'</script>")
if request.method == "POST" and request.url.path.endswith("/actions/runs/91/jobs/3/rerun"):
assert request.headers["x-csrf-token"] == "csrf-123"
assert b"_csrf=csrf-123" in request.content
return httpx.Response(303, headers={"location": "/git/stackchain/api/actions/runs/92"})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.retry_action_job(
"stackchain/api", 7, "abc1234", 91, 3
)
finally:
await gitea_proxy.stop_client()
assert result == {
"head_sha": "abc1234",
"run_id": 91,
"job_index": 3,
"status": "queued",
}
assert requests == [
("GET", "/git/api/v1/repos/stackchain/api/pulls/7", None),
("GET", "/git/api/v1/repos/stackchain/api/commits/abc1234/status", None),
("GET", "/git/stackchain/api/actions/runs/91/jobs/3", None),
("POST", "/git/stackchain/api/actions/runs/91/jobs/3/rerun", "csrf-123"),
]
@pytest.mark.anyio
async def test_retry_action_endpoint_requires_workspace_access_and_exact_head(monkeypatch):
calls = []
async def capabilities(repository, number):
calls.append(("access", repository, number))
return {"authored": True, "assigned": False}
async def retry(repository, number, head_sha, run_id, job_index):
calls.append(("retry", repository, number, head_sha, run_id, job_index))
return {
"head_sha": head_sha,
"run_id": run_id,
"job_index": job_index,
"status": "queued",
}
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry, 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/checks/91/jobs/3/retry",
json={"expected_head_sha": "abc1234"},
)
assert response.status_code == 202
assert response.headers["cache-control"] == "no-store"
assert response.json()["status"] == "queued"
assert calls == [
("access", "stackchain/api", 7),
("retry", "stackchain/api", 7, "abc1234", 91, 3),
]
@pytest.mark.anyio
async def test_gitea_assigned_pull_review_includes_bounded_diff_previews():
async def handler(request):
@@ -1208,7 +1547,7 @@ async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_curren
@pytest.mark.anyio
async def test_gitea_pull_review_request_filters_candidates_and_confirms_requested_reviewer():
async def test_gitea_authored_unassigned_pull_review_request_filters_candidates_and_confirms_reviewer():
requests = []
review_requested = False
@@ -1230,8 +1569,8 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
requested.append({"login": "casey"})
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "user": {"login": "alex"},
"assignees": [{"login": "timmy"}],
"head": {"sha": "abc123"}, "user": {"login": "timmy"},
"assignees": [],
"requested_reviewers": requested,
})
if request.method == "POST" and request.url.path.endswith("/requested_reviewers"):
@@ -1248,7 +1587,10 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
finally:
await gitea_proxy.stop_client()
assert candidates == [{"login": "casey", "name": "Casey"}]
assert candidates == [
{"login": "alex", "name": "Alexander"},
{"login": "casey", "name": "Casey"},
]
assert result == {
"repository": "stackchain/api", "number": 7, "head_sha": "abc123",
"requested_reviewers": ["sam", "casey"], "reviewer": "casey",
@@ -1259,7 +1601,7 @@ async def test_gitea_pull_review_request_filters_candidates_and_confirms_request
@pytest.mark.anyio
async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal():
async def test_gitea_authored_unassigned_pull_can_cancel_pending_review():
requests = []
cancelled = False
@@ -1271,7 +1613,7 @@ async def test_gitea_cancel_pending_pull_review_confirms_authoritative_removal()
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"head": {"sha": "abc123"}, "assignees": [{"login": "timmy"}],
"head": {"sha": "abc123"}, "user": {"login": "timmy"}, "assignees": [],
"requested_reviewers": [{"login": "casey"}] if cancelled else [{"login": "sam"}, {"login": "casey"}],
})
if request.method == "DELETE" and request.url.path.endswith("/requested_reviewers"):
+1
View File
@@ -108,6 +108,7 @@ async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch
"name": "lint/<unsafe>", "state": "failure",
"description": "Formatting failed " + "x" * 222,
"url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4",
"recovery": {"run_id": 9, "job_index": 4},
},
{"name": "external", "state": "pending", "description": "Waiting", "url": ""},
{"name": "build-release", "state": "success", "description": "Passed", "url": ""},
+30
View File
@@ -107,6 +107,36 @@ async def test_resolve_work_route_returns_only_a_requested_review():
}
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_authored_unassigned_pull():
async def upstream(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json={
"id": 703,
"number": 90,
"title": "Ship authored flow",
"state": "open",
"html_url": gitea_proxy.GITEA_URL + "/stackchain/dashboard/pulls/90",
"assignees": [],
"requested_reviewers": [],
"user": {"login": "timmy"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.resolve_work_route(
"pull", "stackchain/dashboard", 90, None
)
finally:
await gitea_proxy.stop_client()
assert result["kind"] == "pull"
assert result["number"] == 90
assert result["is_assigned"] is False
assert result["work_reasons"] == ["authored_by_me"]
@pytest.mark.anyio
async def test_resolve_work_route_returns_an_open_issue_filed_by_the_current_user():
requests = []