Compare commits
No commits in common. "f8ab6977edfe52945871e33f8d11c22af1a2d46a" and "8c4969cc445dacf32e30382b1f6280c090950d3a" have entirely different histories.
f8ab6977ed
...
8c4969cc44
|
|
@ -1287,7 +1287,7 @@
|
||||||
if (continuing) return await completeOwnershipExitToday(item);
|
if (continuing) return await completeOwnershipExitToday(item);
|
||||||
paintMyWork(lastContextSnapshot);
|
paintMyWork(lastContextSnapshot);
|
||||||
return false;
|
return false;
|
||||||
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
|
});
|
||||||
}
|
}
|
||||||
attachReleaseReceipt();
|
attachReleaseReceipt();
|
||||||
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
|
@ -4632,7 +4632,6 @@
|
||||||
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
qs('#pull-sheet-title').textContent = item.title || 'Assigned pull request';
|
||||||
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
qs('#pull-sheet-status').textContent = 'Loading pull request…';
|
||||||
qs('#pull-sheet-body').textContent = '';
|
qs('#pull-sheet-body').textContent = '';
|
||||||
|
|
||||||
qs('#pull-files').textContent = '';
|
qs('#pull-files').textContent = '';
|
||||||
qs('#pull-review').open = false;
|
qs('#pull-review').open = false;
|
||||||
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
|
qs('#pull-review-status').textContent = 'Expand to load changed files and merge readiness.';
|
||||||
|
|
@ -4664,7 +4663,6 @@
|
||||||
pullConversation = pullController.conversation(item, detail.conversation);
|
pullConversation = pullController.conversation(item, detail.conversation);
|
||||||
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
qs('#pull-sheet-title').textContent = detail.title || 'Assigned pull request';
|
||||||
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
qs('#pull-sheet-body').innerHTML = renderMarkdown(detail.body || 'No description provided.');
|
||||||
|
|
||||||
renderPullConversation(pullConversation.snapshot());
|
renderPullConversation(pullConversation.snapshot());
|
||||||
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
qs('#open-pull-gitea').href = detail.url || item.url || '#';
|
||||||
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
qs('#pull-sheet-status').textContent = 'Pull request ready · by ' + (detail.author || 'unknown author');
|
||||||
|
|
|
||||||
|
|
@ -1651,16 +1651,6 @@
|
||||||
<div id="pull-sheet-status" class="small" aria-live="polite">Choose a pull request.</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>
|
<button class="pull-retry" id="retry-pull-load" type="button" hidden>Retry loading pull request</button>
|
||||||
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
<div class="pull-sheet-content markdown-content" id="pull-sheet-body"></div>
|
||||||
<button id="edit-pull-content" type="button" hidden>Edit review context</button>
|
|
||||||
<form class="issue-edit-form" id="pull-edit-form" hidden>
|
|
||||||
<label>Title<input id="pull-edit-title" maxlength="255" required /></label>
|
|
||||||
<label>Description<textarea id="pull-edit-body" maxlength="10000"></textarea></label>
|
|
||||||
<div class="issue-edit-actions">
|
|
||||||
<button id="save-pull-content" type="submit">Save context</button>
|
|
||||||
<button id="cancel-pull-edit" type="button">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<div id="pull-edit-status" class="small" role="status" aria-live="assertive"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<section id="pull-conversation" tabindex="-1">
|
<section id="pull-conversation" tabindex="-1">
|
||||||
<h2>Full conversation</h2><div id="pull-comments"></div>
|
<h2>Full conversation</h2><div id="pull-comments"></div>
|
||||||
|
|
|
||||||
|
|
@ -99,9 +99,6 @@ function resetOwnershipControls(doc, item, checkpointed) {
|
||||||
qs('#release-pull').disabled = false;
|
qs('#release-pull').disabled = false;
|
||||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
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 = 'Load teammates to transfer ownership.';
|
||||||
qs('#edit-pull-content').hidden = true;
|
|
||||||
qs('#pull-edit-form').hidden = true;
|
|
||||||
qs('#pull-edit-status').textContent = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetReviewRequestControls(doc, detail) {
|
function resetReviewRequestControls(doc, detail) {
|
||||||
|
|
@ -121,10 +118,7 @@ function bindReviewRequestControls(doc, controller, getSelected) {
|
||||||
const load = qs('#load-pull-reviewers');
|
const load = qs('#load-pull-reviewers');
|
||||||
if (load.dataset.reviewRequestBound === 'true') return;
|
if (load.dataset.reviewRequestBound === 'true') return;
|
||||||
load.dataset.reviewRequestBound = 'true';
|
load.dataset.reviewRequestBound = 'true';
|
||||||
controller.setReviewDetail = detail => {
|
controller.setReviewDetail = detail => resetReviewRequestControls(doc, detail);
|
||||||
resetReviewRequestControls(doc, detail);
|
|
||||||
controller.edit?.setDetail(detail, Boolean(detail?.saved_at));
|
|
||||||
};
|
|
||||||
resetReviewRequestControls(doc, null);
|
resetReviewRequestControls(doc, null);
|
||||||
load.addEventListener('click', async () => {
|
load.addEventListener('click', async () => {
|
||||||
const selected = getSelected();
|
const selected = getSelected();
|
||||||
|
|
@ -169,81 +163,8 @@ function bindReviewRequestControls(doc, controller, getSelected) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindContextEditor(doc, controller, getSelected, getDetail, getLogin) {
|
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||||
const qs = selector => doc.querySelector(selector);
|
|
||||||
const form = qs('#pull-edit-form');
|
|
||||||
const controls = () => form.querySelectorAll('input,textarea,button');
|
|
||||||
const draft = () => ({
|
|
||||||
title:qs('#pull-edit-title').value.trim(), body:qs('#pull-edit-body').value,
|
|
||||||
expectedHeadSha:getDetail()?.head_sha || '',
|
|
||||||
});
|
|
||||||
qs('#edit-pull-content').addEventListener('click', () => {
|
|
||||||
const item = getSelected(), detail = getDetail();
|
|
||||||
if (!item || !detail) return;
|
|
||||||
const saved = controller.loadEditDraft(item) || {
|
|
||||||
title:detail.title || '', body:detail.body || '', expectedHeadSha:detail.head_sha || '',
|
|
||||||
};
|
|
||||||
qs('#pull-edit-title').value = saved.title;
|
|
||||||
qs('#pull-edit-body').value = saved.body;
|
|
||||||
form.hidden = false;
|
|
||||||
qs('#pull-edit-status').textContent = '';
|
|
||||||
qs('#pull-edit-title').focus();
|
|
||||||
});
|
|
||||||
qs('#cancel-pull-edit').addEventListener('click', () => {
|
|
||||||
form.hidden = true;
|
|
||||||
qs('#edit-pull-content').focus();
|
|
||||||
});
|
|
||||||
['#pull-edit-title', '#pull-edit-body'].forEach(selector => qs(selector).addEventListener('input', () => {
|
|
||||||
const item = getSelected();
|
|
||||||
if (item && getDetail()) controller.saveEditDraft(item, draft());
|
|
||||||
}));
|
|
||||||
form.addEventListener('submit', async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
const item = getSelected(), update = draft();
|
|
||||||
if (!item || !getDetail()) return;
|
|
||||||
if (!update.title) {
|
|
||||||
qs('#pull-edit-status').textContent = 'Title is required.';
|
|
||||||
qs('#pull-edit-title').focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controls().forEach(control => { control.disabled = true; });
|
|
||||||
qs('#pull-edit-status').textContent = 'Saving review context…';
|
|
||||||
try {
|
|
||||||
const result = await controller.updateContent(item, update);
|
|
||||||
if (getSelected() !== item) return;
|
|
||||||
const detail = getDetail();
|
|
||||||
Object.assign(detail, result);
|
|
||||||
item.title = result.title;
|
|
||||||
qs('#pull-sheet-title').textContent = result.title;
|
|
||||||
qs('#pull-sheet-body').innerHTML = renderMarkdown(result.body || 'No description provided.');
|
|
||||||
controller.setReviewDetail(detail);
|
|
||||||
qs('#pull-sheet-status').textContent = 'Review context updated · ready to request review';
|
|
||||||
form.hidden = true;
|
|
||||||
qs('#pull-edit-status').textContent = '';
|
|
||||||
qs('#edit-pull-content').focus();
|
|
||||||
} catch (error) {
|
|
||||||
qs('#pull-edit-status').textContent = error.message + ' Your change is still here; retry.';
|
|
||||||
qs('#pull-edit-title').focus();
|
|
||||||
} finally {
|
|
||||||
controls().forEach(control => { control.disabled = false; });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
reset() {
|
|
||||||
qs('#edit-pull-content').hidden = true;
|
|
||||||
form.hidden = true;
|
|
||||||
qs('#pull-edit-status').textContent = '';
|
|
||||||
},
|
|
||||||
setDetail(detail, offline = false) {
|
|
||||||
const editable = !offline && detail?.state === 'open' && detail.author === getLogin();
|
|
||||||
qs('#edit-pull-content').hidden = !editable;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindOwnershipControls(doc, controller, getSelected, finish, getDetail, getLogin) {
|
|
||||||
bindReviewRequestControls(doc, controller, getSelected);
|
bindReviewRequestControls(doc, controller, getSelected);
|
||||||
controller.edit = bindContextEditor(doc, controller, getSelected, getDetail, getLogin);
|
|
||||||
const qs = selector => doc.querySelector(selector);
|
const qs = selector => doc.querySelector(selector);
|
||||||
const load = qs('#load-pull-handoff');
|
const load = qs('#load-pull-handoff');
|
||||||
if (load.dataset.ownershipBound === 'true') return;
|
if (load.dataset.ownershipBound === 'true') return;
|
||||||
|
|
@ -316,14 +237,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||||
let candidateRequest = null;
|
let candidateRequest = null;
|
||||||
let reviewCandidateRequest = null;
|
let reviewCandidateRequest = null;
|
||||||
let reviewRequestMutation = null;
|
let reviewRequestMutation = null;
|
||||||
let editRequest = null;
|
|
||||||
const reviewRequests = new Map();
|
const reviewRequests = new Map();
|
||||||
const reviewCache = new Map();
|
const reviewCache = new Map();
|
||||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||||
const operationKey = item => draftKey(item) + ':operation';
|
const operationKey = item => draftKey(item) + ':operation';
|
||||||
const editDraftKey = item => 'stackchain.pull-edit.v1:' + item.repository + '#' + item.number;
|
|
||||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||||
|
|
||||||
|
|
@ -386,39 +305,6 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||||
}).finally(() => { reviewRequestMutation = null; });
|
}).finally(() => { reviewRequestMutation = null; });
|
||||||
return reviewRequestMutation;
|
return reviewRequestMutation;
|
||||||
},
|
},
|
||||||
loadEditDraft(item) {
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
|
||||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
|
||||||
typeof value.expectedHeadSha === 'string' ? value : null;
|
|
||||||
} catch (_error) { return null; }
|
|
||||||
},
|
|
||||||
saveEditDraft(item, draft) {
|
|
||||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
|
||||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
|
||||||
},
|
|
||||||
updateContent(item, draft) {
|
|
||||||
if (editRequest) return editRequest;
|
|
||||||
this.saveEditDraft(item, draft);
|
|
||||||
editRequest = fetchJson(pathFor(item) + '/content', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: draft.title,
|
|
||||||
body: draft.body,
|
|
||||||
expected_head_sha: draft.expectedHeadSha,
|
|
||||||
}),
|
|
||||||
}).then(result => {
|
|
||||||
if (result?.number !== item.number || result?.title !== draft.title ||
|
|
||||||
result?.body !== draft.body || result?.head_sha !== draft.expectedHeadSha) {
|
|
||||||
throw new Error('Pull request update was not confirmed.');
|
|
||||||
}
|
|
||||||
try { storage?.removeItem(editDraftKey(item)); }
|
|
||||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
|
||||||
return result;
|
|
||||||
}).finally(() => { editRequest = null; });
|
|
||||||
return editRequest;
|
|
||||||
},
|
|
||||||
handoff(item, recipient) {
|
handoff(item, recipient) {
|
||||||
if (ownershipRequest) return ownershipRequest;
|
if (ownershipRequest) return ownershipRequest;
|
||||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||||
|
|
@ -518,7 +404,6 @@ createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||||
createPullSheet.bindContextEditor = bindContextEditor;
|
|
||||||
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
createPullSheet.resetReviewRequestControls = resetReviewRequestControls;
|
||||||
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
createPullSheet.bindReviewRequestControls = bindReviewRequestControls;
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||||
|
|
|
||||||
|
|
@ -2703,52 +2703,6 @@ async def is_assigned_pull(repository: str, number: int) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_authored_assigned_pull(
|
|
||||||
repository: str,
|
|
||||||
number: int,
|
|
||||||
title: str,
|
|
||||||
body: str,
|
|
||||||
expected_head_sha: str,
|
|
||||||
) -> dict:
|
|
||||||
path = f"repos/{repository}/pulls/{number}"
|
|
||||||
login, pull = await _current_login_and_target(path)
|
|
||||||
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
|
||||||
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
|
||||||
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"))
|
|
||||||
):
|
|
||||||
raise IssueNotAvailableError("pull request not found")
|
|
||||||
if head.get("sha") != expected_head_sha:
|
|
||||||
raise IssueEditConflictError("pull request head changed upstream")
|
|
||||||
response = await _get_client().patch(
|
|
||||||
f"/api/v1/{path}",
|
|
||||||
headers=_auth(),
|
|
||||||
json={"title": title, "body": body},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
confirmed = response.json()
|
|
||||||
confirmed_head = confirmed.get("head") if isinstance(confirmed.get("head"), dict) else {}
|
|
||||||
if (
|
|
||||||
confirmed.get("number") != number
|
|
||||||
or confirmed.get("title") != title
|
|
||||||
or confirmed.get("body", "") != body
|
|
||||||
or confirmed_head.get("sha") != expected_head_sha
|
|
||||||
):
|
|
||||||
raise ValueError("Gitea did not confirm the pull request update")
|
|
||||||
return {
|
|
||||||
"repository": repository,
|
|
||||||
"number": number,
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"head_sha": expected_head_sha,
|
|
||||||
"state": confirmed.get("state", "open"),
|
|
||||||
"url": _safe_gitea_web_url(confirmed.get("html_url")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def pull_completion_detail(repository: str, number: int) -> dict:
|
async def pull_completion_detail(repository: str, number: int) -> dict:
|
||||||
base = f"repos/{repository}/pulls/{number}"
|
base = f"repos/{repository}/pulls/{number}"
|
||||||
pull = await fetch(base)
|
pull = await fetch(base)
|
||||||
|
|
|
||||||
51
src/main.py
51
src/main.py
|
|
@ -1117,22 +1117,6 @@ class PullReviewRequest(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PullContentUpdate(BaseModel):
|
|
||||||
title: str = Field(min_length=1, max_length=255)
|
|
||||||
body: str = Field(default="", max_length=10_000)
|
|
||||||
expected_head_sha: str = Field(
|
|
||||||
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator("title")
|
|
||||||
@classmethod
|
|
||||||
def strip_title(cls, value: str) -> str:
|
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError("title cannot be blank")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class IssueReassignment(IssueHandoff):
|
class IssueReassignment(IssueHandoff):
|
||||||
expected_assignees: list[str] = Field(min_length=1, max_length=10)
|
expected_assignees: list[str] = Field(min_length=1, max_length=10)
|
||||||
|
|
||||||
|
|
@ -6574,41 +6558,6 @@ async def assigned_pull_detail(owner: str, repo: str, number: int = PathParam(gt
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/content")
|
|
||||||
async def update_authored_assigned_pull_content(
|
|
||||||
update: PullContentUpdate,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
number: int = PathParam(gt=0),
|
|
||||||
):
|
|
||||||
repository = f"{owner}/{repo}"
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
gitea_proxy.update_authored_assigned_pull(
|
|
||||||
repository,
|
|
||||||
number,
|
|
||||||
update.title,
|
|
||||||
update.body,
|
|
||||||
update.expected_head_sha,
|
|
||||||
),
|
|
||||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except gitea_proxy.IssueEditConflictError:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "This pull request changed in Gitea. Your draft is safe; reload the latest pull request before saving."},
|
|
||||||
status_code=409,
|
|
||||||
)
|
|
||||||
except gitea_proxy.IssueNotAvailableError:
|
|
||||||
raise HTTPException(status_code=404, detail="Editable pull request not found")
|
|
||||||
except Exception:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": "The pull request could not be updated. Your draft is safe; please retry."},
|
|
||||||
status_code=503,
|
|
||||||
headers={"Retry-After": "1"},
|
|
||||||
)
|
|
||||||
return JSONResponse(result)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
|
||||||
async def assigned_pull_review_data(
|
async def assigned_pull_review_data(
|
||||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||||
|
|
|
||||||
|
|
@ -7424,80 +7424,6 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_pull_sheet_preserves_context_draft_until_confirmed_update():
|
|
||||||
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 fail = true;
|
|
||||||
const controller = createPullSheet({{
|
|
||||||
storage,
|
|
||||||
fetchJson: async (url, options={{}}) => {{
|
|
||||||
const body = JSON.parse(options.body);
|
|
||||||
calls.push({{url, method:options.method, body}});
|
|
||||||
if (fail) {{ fail = false; throw new Error('network down'); }}
|
|
||||||
return {{number:7,title:body.title,body:body.body,head_sha:body.expected_head_sha,state:'open'}};
|
|
||||||
}},
|
|
||||||
}});
|
|
||||||
const item = {{repository:'stackchain/api',number:7}};
|
|
||||||
const draft = {{title:'Clear review brief',body:'Review mobile handoff.',expectedHeadSha:'abc1234'}};
|
|
||||||
(async () => {{
|
|
||||||
await controller.updateContent(item, draft).catch(() => null);
|
|
||||||
const kept = controller.loadEditDraft(item);
|
|
||||||
const result = await controller.updateContent(item, draft);
|
|
||||||
process.stdout.write(JSON.stringify({{kept,result,after:controller.loadEditDraft(item),calls}}));
|
|
||||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
||||||
"""
|
|
||||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
||||||
output = json.loads(result.stdout)
|
|
||||||
assert output["kept"] == {
|
|
||||||
"title": "Clear review brief",
|
|
||||||
"body": "Review mobile handoff.",
|
|
||||||
"expectedHeadSha": "abc1234",
|
|
||||||
}
|
|
||||||
assert output["after"] is None
|
|
||||||
assert output["result"]["head_sha"] == "abc1234"
|
|
||||||
assert output["calls"] == [
|
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
|
||||||
"method": "PATCH",
|
|
||||||
"body": {
|
|
||||||
"title": "Clear review brief",
|
|
||||||
"body": "Review mobile handoff.",
|
|
||||||
"expected_head_sha": "abc1234",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/api/pulls/7/content",
|
|
||||||
"method": "PATCH",
|
|
||||||
"body": {
|
|
||||||
"title": "Clear review brief",
|
|
||||||
"body": "Review mobile handoff.",
|
|
||||||
"expected_head_sha": "abc1234",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_pull_overview_exposes_author_only_context_editor_with_retry_safe_status():
|
|
||||||
html = (Path(__file__).parents[1] / "frontend" / "index.html").read_text()
|
|
||||||
source = await dashboard()
|
|
||||||
pull_source = PULL_SHEET.read_text()
|
|
||||||
|
|
||||||
assert 'id="edit-pull-content"' in html
|
|
||||||
assert 'id="pull-edit-form"' in html
|
|
||||||
assert 'id="pull-edit-title"' in html
|
|
||||||
assert 'id="pull-edit-body"' in html
|
|
||||||
assert 'id="save-pull-content"' in html
|
|
||||||
assert "detail.author === getLogin()" in pull_source
|
|
||||||
assert "controller.loadEditDraft(item)" in pull_source
|
|
||||||
assert "controller.updateContent(item" in pull_source
|
|
||||||
assert "Your change is still here; retry." in pull_source
|
|
||||||
assert "controller.setReviewDetail(detail)" in pull_source
|
|
||||||
|
|
||||||
|
|
||||||
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
def test_pull_sheet_removes_one_pull_from_snapshot_without_touching_other_work():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||||
|
|
@ -7537,9 +7463,6 @@ const elements = new Map([
|
||||||
['#load-pull-handoff', {{disabled:true}}],
|
['#load-pull-handoff', {{disabled:true}}],
|
||||||
['#release-pull', {{disabled:true,textContent:''}}],
|
['#release-pull', {{disabled:true,textContent:''}}],
|
||||||
['#pull-handoff-status', {{textContent:''}}],
|
['#pull-handoff-status', {{textContent:''}}],
|
||||||
['#edit-pull-content', {{hidden:false}}],
|
|
||||||
['#pull-edit-form', {{hidden:false}}],
|
|
||||||
['#pull-edit-status', {{textContent:'stale'}}],
|
|
||||||
]);
|
]);
|
||||||
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
const select = {{textContent:'stale', appendChild:node => children.push(node)}};
|
||||||
const doc = {{
|
const doc = {{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -8,90 +7,6 @@ from src import gitea_proxy, main
|
||||||
from src.security_event_store import SecurityEventStoreError
|
from src.security_event_store import SecurityEventStoreError
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_pull_author_can_update_assigned_open_pull_context(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def update(repository, number, title, body, expected_head_sha):
|
|
||||||
calls.append((repository, number, title, body, expected_head_sha))
|
|
||||||
return {
|
|
||||||
"repository": repository,
|
|
||||||
"number": number,
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
"head_sha": expected_head_sha,
|
|
||||||
"state": "open",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
main.gitea_proxy, "update_authored_assigned_pull", update, raising=False
|
|
||||||
)
|
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
||||||
response = await client.patch(
|
|
||||||
"/api/v1/repos/stackchain/api/pulls/7/content",
|
|
||||||
json={
|
|
||||||
"title": "Clarify mobile handoff",
|
|
||||||
"body": "Explain the reviewer path.",
|
|
||||||
"expected_head_sha": "abc1234",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["title"] == "Clarify mobile handoff"
|
|
||||||
assert calls == [
|
|
||||||
(
|
|
||||||
"stackchain/api",
|
|
||||||
7,
|
|
||||||
"Clarify mobile handoff",
|
|
||||||
"Explain the reviewer path.",
|
|
||||||
"abc1234",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_gitea_pull_context_update_requires_author_assignment_and_matching_head():
|
|
||||||
requests = []
|
|
||||||
|
|
||||||
async def handler(request):
|
|
||||||
requests.append((request.method, request.url.path, request.content))
|
|
||||||
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" 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"}],
|
|
||||||
"head": {"sha": "abc1234"},
|
|
||||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
|
||||||
})
|
|
||||||
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7" and request.method == "PATCH":
|
|
||||||
assert json.loads(request.content) == {
|
|
||||||
"title": "Clear review brief", "body": "Review the mobile handoff."
|
|
||||||
}
|
|
||||||
return httpx.Response(200, json={
|
|
||||||
"number": 7, "title": "Clear review brief", "body": "Review the mobile handoff.",
|
|
||||||
"state": "open", "head": {"sha": "abc1234"},
|
|
||||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
|
||||||
})
|
|
||||||
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
|
|
||||||
|
|
||||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
|
||||||
try:
|
|
||||||
result = await gitea_proxy.update_authored_assigned_pull(
|
|
||||||
"stackchain/api", 7, "Clear review brief", "Review the mobile handoff.", "abc1234"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await gitea_proxy.stop_client()
|
|
||||||
|
|
||||||
assert result["title"] == "Clear review brief"
|
|
||||||
assert [request[:2] for request in requests] == [
|
|
||||||
("GET", "/api/v1/user"),
|
|
||||||
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
|
|
||||||
("PATCH", "/api/v1/repos/stackchain/api/pulls/7"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
|
async def test_assigned_pull_detail_reports_completion_state(monkeypatch):
|
||||||
async def assigned(repository, number):
|
async def assigned(repository, number):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user