feat: create mobile pull requests from pushed branches (Closes #1362)
This commit is contained in:
parent
ae98564d9b
commit
e5b9833096
203
frontend/create-pull-sheet.js
Normal file
203
frontend/create-pull-sheet.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
function createController({request, storage, login, createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now())}) {
|
||||
const storageKey = 'stackchain.create-pull.v1.' + String(login || '').toLowerCase();
|
||||
let operationId = createId();
|
||||
let current = {
|
||||
repository:'', branches:[], head:'', base:'', expected_head_sha:'',
|
||||
title:'', body:'', draft:true,
|
||||
};
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem?.(storageKey) || 'null');
|
||||
if (saved && typeof saved === 'object') {
|
||||
for (const field of ['repository', 'head', 'base', 'title', 'body']) {
|
||||
if (typeof saved[field] === 'string') current[field] = saved[field];
|
||||
}
|
||||
if (typeof saved.draft === 'boolean') current.draft = saved.draft;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function snapshot() {
|
||||
return JSON.parse(JSON.stringify(current));
|
||||
}
|
||||
|
||||
function persist() {
|
||||
storage?.setItem?.(storageKey, JSON.stringify({
|
||||
repository:current.repository, head:current.head, base:current.base,
|
||||
title:current.title, body:current.body, draft:current.draft,
|
||||
}));
|
||||
}
|
||||
|
||||
async function selectRepository(repository) {
|
||||
const options = await request('api/v1/repos/' + repository + '/pull-creation-options');
|
||||
const branches = Array.isArray(options?.branches) ? options.branches : [];
|
||||
const base = branches.some(branch => branch.name === options.default_branch) ? options.default_branch : (branches[0]?.name || '');
|
||||
const headBranch = branches.find(branch => branch.name !== base) || branches[0] || {};
|
||||
current = {
|
||||
...current, repository, branches, base,
|
||||
head:headBranch.name || '', expected_head_sha:headBranch.sha || '',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function update(values) {
|
||||
current = {...current, ...values};
|
||||
if (Object.prototype.hasOwnProperty.call(values, 'head')) {
|
||||
current.expected_head_sha = current.branches.find(branch => branch.name === values.head)?.sha || '';
|
||||
}
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const result = await request('api/v1/repos/' + current.repository + '/pulls', {
|
||||
method:'POST',
|
||||
headers:{'Idempotency-Key':operationId},
|
||||
body:{
|
||||
head:current.head, base:current.base, title:current.title, body:current.body,
|
||||
draft:current.draft, expected_head_sha:current.expected_head_sha,
|
||||
},
|
||||
});
|
||||
operationId = createId();
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
state:snapshot,
|
||||
selectRepository,
|
||||
update,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
function createBinding({controller, render, status, onCreated}) {
|
||||
return {
|
||||
async repositoryChanged(repository) {
|
||||
status('Loading branches…');
|
||||
try {
|
||||
const state = await controller.selectRepository(repository);
|
||||
render(state);
|
||||
status(state.branches.length ? 'Choose the source and base branches.' : 'No branches are available.');
|
||||
return state;
|
||||
} catch (error) {
|
||||
status(error.message || 'Branches could not be loaded.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
changed(values) {
|
||||
const state = controller.update(values);
|
||||
render(state);
|
||||
return state;
|
||||
},
|
||||
async submit() {
|
||||
status('Creating pull request…');
|
||||
try {
|
||||
const result = await controller.submit();
|
||||
status(result.existing ? `Pull request #${result.number} is already open.` : `Pull request #${result.number} created.`);
|
||||
onCreated(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
status(error.message || 'The pull request could not be created. Your draft is safe.');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function bindDashboard() {
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const switchButton = qs('#switch-to-create-pull');
|
||||
switchButton.disabled = true;
|
||||
const identityResponse = await fetch('api/v1/background-identity').catch(()=>null);
|
||||
const identity = await identityResponse?.json().catch(()=>({}));
|
||||
if (!identityResponse?.ok || !identity?.login) {
|
||||
switchButton.title = 'Account identity is unavailable.';
|
||||
return null;
|
||||
}
|
||||
const login = identity.login;
|
||||
const root = qs('#create-pull-sheet');
|
||||
const repository = qs('#create-pull-repository');
|
||||
const head = qs('#create-pull-head');
|
||||
const base = qs('#create-pull-base');
|
||||
const title = qs('#create-pull-title');
|
||||
const body = qs('#create-pull-body');
|
||||
const submit = qs('#submit-create-pull');
|
||||
const request = async (url, options) => {
|
||||
const response = await fetch(url, options ? {
|
||||
method:options.method,
|
||||
headers:{'Content-Type':'application/json', ...options.headers},
|
||||
body:JSON.stringify(options.body),
|
||||
} : undefined);
|
||||
const payload = await response.json().catch(()=>({}));
|
||||
if (!response.ok) throw new Error(payload.error || payload.detail || 'Request failed.');
|
||||
return payload;
|
||||
};
|
||||
const controller = createController({request, storage:localStorage, login});
|
||||
const setOptions = (select, branches) => {
|
||||
select.replaceChildren(...branches.map(branch => {
|
||||
const option = document.createElement('option');
|
||||
option.value = option.textContent = branch.name;
|
||||
return option;
|
||||
}));
|
||||
};
|
||||
const render = state => {
|
||||
setOptions(head, state.branches);
|
||||
setOptions(base, state.branches);
|
||||
head.value = state.head;
|
||||
base.value = state.base;
|
||||
head.disabled = base.disabled = !state.branches.length;
|
||||
qs('#create-pull-head-receipt').textContent = state.expected_head_sha ? 'Source at ' + state.expected_head_sha.slice(0, 12) : '';
|
||||
submit.disabled = !(state.repository && state.head && state.base && state.head !== state.base && title.value.trim());
|
||||
};
|
||||
const binding = createBinding({
|
||||
controller, render,
|
||||
status:message=>{ qs('#create-pull-status').textContent = message; },
|
||||
onCreated:result=>{
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
location.hash = '#/my-work/pull/' + result.repository + '/' + result.number;
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
const update = () => binding.changed({
|
||||
head:head.value, base:base.value, title:title.value, body:body.value,
|
||||
draft:document.querySelector('input[name="create-pull-mode"]:checked')?.value !== 'ready',
|
||||
});
|
||||
repository.addEventListener('change', () => binding.repositoryChanged(repository.value).catch(()=>{}));
|
||||
[head,base,title,body].forEach(field => field.addEventListener('input', update));
|
||||
document.querySelectorAll('input[name="create-pull-mode"]').forEach(field => field.addEventListener('change', () => {
|
||||
update();
|
||||
submit.textContent = field.value === 'ready' && field.checked ? 'Create ready pull request' : 'Create draft pull request';
|
||||
}));
|
||||
qs('#create-pull-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
update();
|
||||
binding.submit().catch(()=>{});
|
||||
});
|
||||
const close = () => {
|
||||
root.hidden = true;
|
||||
qs('main').inert = false;
|
||||
qs('#new-issue').focus();
|
||||
};
|
||||
qs('#cancel-create-pull').addEventListener('click', close);
|
||||
switchButton.addEventListener('click', () => {
|
||||
qs('#cancel-new-issue').click();
|
||||
repository.replaceChildren(...Array.from(qs('#create-issue-repository').options).map(source => {
|
||||
const option = document.createElement('option');
|
||||
option.value = source.value;
|
||||
option.textContent = source.textContent;
|
||||
return option;
|
||||
}));
|
||||
root.hidden = false;
|
||||
qs('main').inert = true;
|
||||
qs('#cancel-create-pull').focus();
|
||||
});
|
||||
root.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
});
|
||||
switchButton.disabled = false;
|
||||
return binding;
|
||||
}
|
||||
|
||||
const cp = {createController, createBinding, b:bindDashboard};
|
||||
if (typeof module !== 'undefined') module.exports = cp;
|
||||
else cp.b();
|
||||
|
|
@ -1539,3 +1539,16 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
.create-pull-sheet{position:fixed;inset:0;z-index:75;background:rgba(3,7,18,.72);display:grid;place-items:end center;padding:16px}
|
||||
.create-pull-sheet[hidden]{display:none}
|
||||
.create-pull-panel{width:min(100%,680px);max-height:calc(100dvh - 32px);overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:20px;padding-bottom:max(20px,env(safe-area-inset-bottom))}
|
||||
.create-pull-header{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.create-pull-header h3{margin:2px 0 0}
|
||||
.create-pull-panel form,.create-pull-panel label{display:grid;gap:6px}
|
||||
.create-pull-panel form{gap:14px}
|
||||
.create-pull-branches{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.create-pull-mode{display:flex;gap:18px;border:1px solid var(--border);border-radius:12px;padding:10px 12px}
|
||||
.create-pull-mode label{display:flex;align-items:center;gap:7px}
|
||||
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
|
||||
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
|
||||
|
|
|
|||
|
|
@ -1281,6 +1281,7 @@
|
|||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="switch-to-create-pull" type="button">Pull request</button>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden>
|
||||
|
|
@ -2171,6 +2172,34 @@
|
|||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="create-pull-sheet" id="create-pull-sheet" role="dialog" aria-modal="true" aria-labelledby="create-pull-heading" hidden>
|
||||
<section class="create-pull-panel">
|
||||
<header class="create-pull-header">
|
||||
<div><span class="eyebrow">New</span><h3 id="create-pull-heading">Pull request</h3></div>
|
||||
<button id="cancel-create-pull" type="button">Cancel</button>
|
||||
</header>
|
||||
<p class="small">Open a pull request from a branch already pushed to Gitea.</p>
|
||||
<form id="create-pull-form">
|
||||
<label for="create-pull-repository">Repository
|
||||
<select id="create-pull-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
<div class="create-pull-branches">
|
||||
<label for="create-pull-head">Source branch<select id="create-pull-head" required disabled></select></label>
|
||||
<label for="create-pull-base">Base branch<select id="create-pull-base" required disabled></select></label>
|
||||
</div>
|
||||
<output id="create-pull-head-receipt" class="small" aria-live="polite"></output>
|
||||
<label for="create-pull-title">Title<input id="create-pull-title" maxlength="255" required autocomplete="off" /></label>
|
||||
<label for="create-pull-body">Context <span class="small">Optional · Markdown</span><textarea id="create-pull-body" maxlength="10000" rows="5"></textarea></label>
|
||||
<fieldset class="create-pull-mode"><legend>Start as</legend>
|
||||
<label><input type="radio" name="create-pull-mode" value="draft" checked /> Draft</label>
|
||||
<label><input type="radio" name="create-pull-mode" value="ready" /> Ready for review</label>
|
||||
</fieldset>
|
||||
<p id="create-pull-status" role="status" aria-live="polite"></p>
|
||||
<button id="submit-create-pull" type="submit" disabled>Create draft pull request</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
|
|
@ -2259,6 +2288,7 @@
|
|||
<script src="static/voice-issue-capture.js"></script>
|
||||
<script src="static/voice-conversation-capture.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-pull-sheet.js"></script>
|
||||
<script src="static/mobile-create-issue-nav.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ const SHELL = [
|
|||
BASE + 'static/voice-issue-capture.js',
|
||||
BASE + 'static/voice-conversation-capture.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-pull-sheet.js',
|
||||
BASE + 'static/mobile-create-issue-nav.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
|||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
|
|
|
|||
|
|
@ -177,6 +177,10 @@ class PullUpdateConflictError(ValueError):
|
|||
"""Raised when Gitea cannot merge a pull request's base into its head."""
|
||||
|
||||
|
||||
class PullCreateConflictError(ValueError):
|
||||
"""Raised when the selected source branch changed before pull creation."""
|
||||
|
||||
|
||||
class IssueDependencyInvalidError(ValueError):
|
||||
"""Raised when a requested blocker relationship is not valid."""
|
||||
|
||||
|
|
@ -369,6 +373,20 @@ async def repository_access(repository: str) -> dict | None:
|
|||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
async def repo_branches(repository: str) -> list[dict]:
|
||||
"""Return the bounded branch choices visible in one repository."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/branches",
|
||||
headers=_auth(),
|
||||
params={"page": 1, "limit": 100},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea branch response was not a list")
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
|
||||
|
||||
WORK_SEARCHES = {
|
||||
"issue": ("assigned=true", "issues", None),
|
||||
"filed": ("created=true", "issues", "created_by_me"),
|
||||
|
|
@ -1572,6 +1590,96 @@ async def repo_milestones(repository: str) -> list[dict]:
|
|||
]
|
||||
|
||||
|
||||
async def create_pull(
|
||||
repository: str,
|
||||
*,
|
||||
head: str,
|
||||
base: str,
|
||||
title: str,
|
||||
body: str,
|
||||
draft: bool,
|
||||
expected_head_sha: str,
|
||||
) -> dict:
|
||||
"""Create one pull only after revalidating its source and duplicate identity."""
|
||||
branch_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/branches/{quote(head, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
if branch_response.status_code == 404:
|
||||
raise PullCreateConflictError("Source branch is no longer available")
|
||||
branch_response.raise_for_status()
|
||||
branch = branch_response.json()
|
||||
commit = branch.get("commit", {}) if isinstance(branch, dict) else {}
|
||||
if commit.get("id") != expected_head_sha:
|
||||
raise PullCreateConflictError("Source branch changed before pull creation")
|
||||
|
||||
existing_response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}/pulls",
|
||||
headers=_auth(),
|
||||
params={"state": "open", "page": 1, "limit": 50},
|
||||
)
|
||||
existing_response.raise_for_status()
|
||||
existing_items = existing_response.json()
|
||||
if not isinstance(existing_items, list):
|
||||
raise ValueError("Gitea pull response was not a list")
|
||||
existing = next((
|
||||
item for item in existing_items
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("head"), dict)
|
||||
and isinstance(item.get("base"), dict)
|
||||
and item["head"].get("ref") == head
|
||||
and item["base"].get("ref") == base
|
||||
and item.get("state") == "open"
|
||||
), None)
|
||||
pull = existing
|
||||
if pull is None:
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/pulls",
|
||||
headers=_auth(),
|
||||
json={
|
||||
"head": head,
|
||||
"base": base,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": draft,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
pull = response.json()
|
||||
|
||||
if not isinstance(pull, dict) or not isinstance(pull.get("number"), int):
|
||||
raise ValueError("Gitea did not confirm pull creation")
|
||||
pull_head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
|
||||
pull_base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
|
||||
pull_user = pull.get("user") if isinstance(pull.get("user"), dict) else {}
|
||||
if (
|
||||
pull.get("state") != "open"
|
||||
or pull_head.get("ref") != head
|
||||
or pull_head.get("sha") != expected_head_sha
|
||||
or pull_base.get("ref") != base
|
||||
or not isinstance(pull_user.get("login"), str)
|
||||
or (existing is None and (
|
||||
pull.get("title") != title
|
||||
or pull.get("body", "") != body
|
||||
or bool(pull.get("draft")) is not draft
|
||||
))
|
||||
):
|
||||
raise ValueError("Gitea did not confirm the requested pull")
|
||||
return {
|
||||
"number": pull["number"],
|
||||
"repository": repository,
|
||||
"title": pull.get("title", ""),
|
||||
"body": pull.get("body", ""),
|
||||
"state": "open",
|
||||
"draft": bool(pull.get("draft")),
|
||||
"head": {"ref": head, "sha": expected_head_sha},
|
||||
"base": {"ref": base},
|
||||
"author": pull_user["login"],
|
||||
"url": _safe_gitea_web_url(pull.get("html_url")),
|
||||
"existing": existing is not None,
|
||||
}
|
||||
|
||||
|
||||
async def create_issue(
|
||||
repository: str,
|
||||
title: str,
|
||||
|
|
|
|||
119
src/main.py
119
src/main.py
|
|
@ -1139,6 +1139,36 @@ class PullContentUpdate(BaseModel):
|
|||
return value
|
||||
|
||||
|
||||
class PullCreateRequest(BaseModel):
|
||||
head: str = Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_./-]+$")
|
||||
base: str = Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_./-]+$")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
draft: bool = True
|
||||
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
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def strip_body(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_different_branches(self):
|
||||
if self.head == self.base:
|
||||
raise ValueError("source and base branches must differ")
|
||||
return self
|
||||
|
||||
|
||||
class IssueReassignment(IssueHandoff):
|
||||
expected_assignees: list[str] = Field(min_length=1, max_length=10)
|
||||
|
||||
|
|
@ -3803,6 +3833,95 @@ async def repository_page(
|
|||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/pull-creation-options")
|
||||
async def pull_creation_options(owner: str, repo: str) -> JSONResponse:
|
||||
"""Return the current, writable branch choices for mobile pull creation."""
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
access = await asyncio.wait_for(
|
||||
gitea_proxy.repository_access(repository),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
permissions = access.get("permissions", {}) if isinstance(access, dict) else {}
|
||||
if not access or permissions.get("push") is not True:
|
||||
raise HTTPException(status_code=404, detail="Writable repository not found")
|
||||
raw_branches = await asyncio.wait_for(
|
||||
gitea_proxy.repo_branches(repository),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
branches = [
|
||||
{"name": item["name"], "sha": item["commit"]["id"]}
|
||||
for item in raw_branches
|
||||
if isinstance(item.get("name"), str)
|
||||
and isinstance(item.get("commit"), dict)
|
||||
and isinstance(item["commit"].get("id"), str)
|
||||
]
|
||||
return JSONResponse(
|
||||
{"default_branch": access.get("default_branch"), "branches": branches},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Branches could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls")
|
||||
async def create_pull(
|
||||
request: PullCreateRequest,
|
||||
owner: str,
|
||||
repo: str,
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
fingerprint = (
|
||||
"create_pull", repository, request.head, request.base, request.title,
|
||||
request.body, request.draft, request.expected_head_sha,
|
||||
)
|
||||
try:
|
||||
access = await gitea_proxy.repository_access(repository)
|
||||
permissions = access.get("permissions", {}) if isinstance(access, dict) else {}
|
||||
if not access or permissions.get("push") is not True:
|
||||
raise HTTPException(status_code=404, detail="Writable repository not found")
|
||||
result = await _run_idempotent_authored_action(
|
||||
gitea_proxy.create_pull(
|
||||
repository,
|
||||
head=request.head,
|
||||
base=request.base,
|
||||
title=request.title,
|
||||
body=request.body,
|
||||
draft=request.draft,
|
||||
expected_head_sha=request.expected_head_sha,
|
||||
),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=fingerprint,
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
return JSONResponse(
|
||||
result,
|
||||
status_code=200 if result.get("existing") else 201,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except gitea_proxy.PullCreateConflictError:
|
||||
return JSONResponse(
|
||||
{"error": "The source branch changed. Refresh branches before creating the pull request."},
|
||||
status_code=409,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The pull request could not be created. Your draft is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/background-identity")
|
||||
async def background_identity() -> JSONResponse:
|
||||
"""Return only the account key required to safely drain a browser outbox."""
|
||||
|
|
|
|||
203
tests/test_create_pull_api.py
Normal file
203
tests/test_create_pull_api.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_pull_creation_operations():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_creation_options_return_visible_repository_branches(monkeypatch):
|
||||
async def access(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return {"full_name": repository, "default_branch": "main", "permissions": {"push": True}}
|
||||
|
||||
async def branches(repository):
|
||||
assert repository == "stackchain/api"
|
||||
return [
|
||||
{"name": "feature/mobile", "commit": {"id": "abc1234"}},
|
||||
{"name": "main", "commit": {"id": "def5678"}},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_branches", branches, 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/pull-creation-options")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"default_branch": "main",
|
||||
"branches": [
|
||||
{"name": "feature/mobile", "sha": "abc1234"},
|
||||
{"name": "main", "sha": "def5678"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_pull_endpoint_returns_confirmed_mobile_pull(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def access(repository):
|
||||
return {"full_name": repository, "permissions": {"push": True}}
|
||||
|
||||
async def create(repository, *, head, base, title, body, draft, expected_head_sha):
|
||||
calls.append((repository, head, base, title, body, draft, expected_head_sha))
|
||||
return {
|
||||
"number": 42,
|
||||
"repository": repository,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": {"ref": head, "sha": expected_head_sha},
|
||||
"base": {"ref": base},
|
||||
"draft": draft,
|
||||
"state": "open",
|
||||
"url": "https://forge.example/stackchain/api/pulls/42",
|
||||
"existing": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_pull", create, 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",
|
||||
headers={"Idempotency-Key": "mobile-create-42"},
|
||||
json={
|
||||
"head": "feature/mobile",
|
||||
"base": "main",
|
||||
"title": " Ship mobile create ",
|
||||
"body": " Context ",
|
||||
"draft": True,
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["number"] == 42
|
||||
assert calls == [
|
||||
("stackchain/api", "feature/mobile", "main", "Ship mobile create", "Context", True, "abc1234")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_pull_revalidates_head_and_confirms_created_pull():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/branches/feature/mobile"):
|
||||
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
|
||||
if request.method == "GET" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(200, json=[])
|
||||
if request.method == "POST" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(201, json={
|
||||
"number": 42,
|
||||
"title": "Ship mobile create",
|
||||
"body": "Context",
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"head": {"ref": "feature/mobile", "sha": "abc1234"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "timmy"},
|
||||
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/42",
|
||||
})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_pull(
|
||||
"stackchain/api",
|
||||
head="feature/mobile",
|
||||
base="main",
|
||||
title="Ship mobile create",
|
||||
body="Context",
|
||||
draft=True,
|
||||
expected_head_sha="abc1234",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 42
|
||||
assert result["head"] == {"ref": "feature/mobile", "sha": "abc1234"}
|
||||
assert result["author"] == "timmy"
|
||||
assert result["existing"] is False
|
||||
mutation = next(request for request in requests if request.method == "POST")
|
||||
assert mutation.read() == b'{"head":"feature/mobile","base":"main","title":"Ship mobile create","body":"Context","draft":true}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_create_pull_returns_existing_source_base_without_mutation():
|
||||
requests = []
|
||||
existing = {
|
||||
"number": 41,
|
||||
"title": "Already open",
|
||||
"body": "Existing context",
|
||||
"state": "open",
|
||||
"draft": False,
|
||||
"head": {"ref": "feature/mobile", "sha": "abc1234"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "timmy"},
|
||||
"html_url": "https://forge.alexanderwhitestone.com/git/stackchain/api/pulls/41",
|
||||
}
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if "/branches/" in request.url.path:
|
||||
return httpx.Response(200, json={"name": "feature/mobile", "commit": {"id": "abc1234"}})
|
||||
if request.method == "GET" and request.url.path.endswith("/pulls"):
|
||||
return httpx.Response(200, json=[existing])
|
||||
return httpx.Response(500)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.create_pull(
|
||||
"stackchain/api", head="feature/mobile", base="main",
|
||||
title="Duplicate attempt", body="", draft=True,
|
||||
expected_head_sha="abc1234",
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 41
|
||||
assert result["existing"] is True
|
||||
assert all(request.method != "POST" for request in requests)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_pull_endpoint_reports_stale_source_without_retrying(monkeypatch):
|
||||
async def access(repository):
|
||||
return {"full_name": repository, "permissions": {"push": True}}
|
||||
|
||||
async def stale(*args, **kwargs):
|
||||
raise gitea_proxy.PullCreateConflictError("Source branch changed before pull creation")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_pull", stale)
|
||||
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",
|
||||
json={
|
||||
"head": "feature/mobile", "base": "main", "title": "Ship it",
|
||||
"expected_head_sha": "abc1234",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The source branch changed. Refresh branches before creating the pull request."
|
||||
}
|
||||
142
tests/test_create_pull_frontend.py
Normal file
142
tests/test_create_pull_frontend.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
MODULE = ROOT / "frontend" / "create-pull-sheet.js"
|
||||
|
||||
|
||||
def run_node(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(MODULE))});
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], text=True, capture_output=True, cwd=ROOT)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_create_pull_controller_loads_branches_and_defaults_base():
|
||||
result = run_node("""
|
||||
const calls=[];
|
||||
const controller=createPullSheet.createController({
|
||||
request:async (url) => {
|
||||
calls.push(url);
|
||||
return {default_branch:'main',branches:[
|
||||
{name:'feature/mobile',sha:'abc1234'},
|
||||
{name:'main',sha:'def5678'}
|
||||
]};
|
||||
},
|
||||
storage:{getItem:()=>null,setItem:()=>{}},
|
||||
login:'timmy'
|
||||
});
|
||||
controller.selectRepository('stackchain/api').then(value => {
|
||||
process.stdout.write(JSON.stringify({value,calls,state:controller.state()}));
|
||||
}).catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["calls"] == ["api/v1/repos/stackchain/api/pull-creation-options"]
|
||||
assert result["state"]["base"] == "main"
|
||||
assert result["state"]["head"] == "feature/mobile"
|
||||
assert result["state"]["expected_head_sha"] == "abc1234"
|
||||
|
||||
|
||||
def test_create_pull_controller_submits_idempotently_and_keeps_draft_on_failure():
|
||||
result = run_node("""
|
||||
const calls=[]; const saved=[]; let fail=true;
|
||||
const controller=createPullSheet.createController({
|
||||
request:async (url, options) => {
|
||||
calls.push([url,options]);
|
||||
if (!options) return {default_branch:'main',branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}]};
|
||||
if (fail) { fail=false; throw new Error('offline'); }
|
||||
return {number:9,repository:'acme/app',head:{ref:'topic',sha:'abc1234'},base:{ref:'main'},draft:true};
|
||||
},
|
||||
storage:{getItem:()=>null,setItem:(key,value)=>saved.push([key,JSON.parse(value)])},
|
||||
login:'timmy', createId:()=> 'stable-id'
|
||||
});
|
||||
(async()=>{
|
||||
await controller.selectRepository('acme/app');
|
||||
controller.update({title:'Ship it',body:'Context'});
|
||||
let error=''; try { await controller.submit(); } catch (caught) { error=caught.message; }
|
||||
const retained=controller.state();
|
||||
const confirmed=await controller.submit();
|
||||
process.stdout.write(JSON.stringify({calls,error,retained,confirmed,saved}));
|
||||
})().catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["error"] == "offline"
|
||||
assert result["retained"]["title"] == "Ship it"
|
||||
posts = [call for call in result["calls"] if call[1]]
|
||||
assert len(posts) == 2
|
||||
assert posts[0][1]["headers"]["Idempotency-Key"] == "stable-id"
|
||||
assert posts[1][1]["headers"]["Idempotency-Key"] == "stable-id"
|
||||
assert posts[0][1]["body"]["expected_head_sha"] == "abc1234"
|
||||
assert result["confirmed"]["number"] == 9
|
||||
|
||||
|
||||
def test_create_pull_controller_restores_only_the_current_accounts_draft():
|
||||
result = run_node("""
|
||||
const values={
|
||||
'stackchain.create-pull.v1.timmy':JSON.stringify({repository:'acme/app',head:'topic',base:'main',title:'Saved title',body:'Saved body',draft:false}),
|
||||
'stackchain.create-pull.v1.alexander':JSON.stringify({repository:'private/repo',title:'Other account'})
|
||||
};
|
||||
const controller=createPullSheet.createController({
|
||||
request:async()=>({}), storage:{getItem:key=>values[key] || null,setItem:()=>{}}, login:'Timmy'
|
||||
});
|
||||
process.stdout.write(JSON.stringify(controller.state()));
|
||||
""")
|
||||
assert result == {
|
||||
"repository": "acme/app",
|
||||
"branches": [],
|
||||
"head": "topic",
|
||||
"base": "main",
|
||||
"expected_head_sha": "",
|
||||
"title": "Saved title",
|
||||
"body": "Saved body",
|
||||
"draft": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_creation_sheet_is_packaged_and_touch_safe():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert 'id="create-pull-sheet" role="dialog" aria-modal="true"' in html
|
||||
assert 'id="create-pull-form"' in html
|
||||
assert 'id="create-pull-repository"' in html
|
||||
assert 'id="create-pull-head"' in html
|
||||
assert 'id="create-pull-base"' in html
|
||||
assert 'id="submit-create-pull"' in html
|
||||
assert 'static/create-pull-sheet.js' in bundle
|
||||
assert ".create-pull-panel button" in css
|
||||
assert "min-height:44px" in css.replace(" ", "")
|
||||
|
||||
|
||||
def test_create_pull_binding_renders_branch_receipt_and_created_pull():
|
||||
result = run_node("""
|
||||
const rendered=[]; const statuses=[]; const opened=[];
|
||||
const controller={
|
||||
selectRepository:async repository=>({repository,branches:[{name:'topic',sha:'abc1234'},{name:'main',sha:'def5678'}],head:'topic',base:'main',expected_head_sha:'abc1234'}),
|
||||
update:values=>values,
|
||||
submit:async()=>({number:12,repository:'acme/app',existing:false})
|
||||
};
|
||||
const binding=createPullSheet.createBinding({controller,
|
||||
render:state=>rendered.push(state), status:value=>statuses.push(value),
|
||||
onCreated:value=>opened.push(value)
|
||||
});
|
||||
(async()=>{
|
||||
await binding.repositoryChanged('acme/app');
|
||||
await binding.submit();
|
||||
process.stdout.write(JSON.stringify({rendered,statuses,opened}));
|
||||
})().catch(error=>{console.error(error);process.exit(1)});
|
||||
""")
|
||||
assert result["rendered"][0]["expected_head_sha"] == "abc1234"
|
||||
assert result["statuses"][-1] == "Pull request #12 created."
|
||||
assert result["opened"][0]["number"] == 12
|
||||
|
||||
|
||||
def test_dashboard_wires_create_pull_inside_lazy_issue_capture_chunk():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
module = MODULE.read_text()
|
||||
assert "cp.b();" not in dashboard
|
||||
assert "else cp.b();" in module
|
||||
|
|
@ -1467,6 +1467,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/voice-issue-capture.js",
|
||||
"/dashboard/static/voice-conversation-capture.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-pull-sheet.js",
|
||||
"/dashboard/static/mobile-create-issue-nav.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user