Merge pull request 'feat: Create pull requests from pushed branches on mobile' (#1363) from timmy/1362-create-pull-requests-from-pushed-branches-on-mob into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m22s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
timmy 2026-08-24 19:52:48 +00:00
commit f3876d359d
10 changed files with 821 additions and 1 deletions

View 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();

View File

@ -1539,3 +1539,16 @@ textarea { resize: vertical; min-height: 120px; }
transition: transform 120ms ease, border-color 120ms ease; 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}}

View File

@ -1281,6 +1281,7 @@
<section class="create-issue-panel"> <section class="create-issue-panel">
<div class="create-issue-header"> <div class="create-issue-header">
<h3 id="create-issue-heading">Capture work</h3> <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> <button id="cancel-new-issue" type="button">Cancel</button>
</div> </div>
<aside class="today-capture-interruption" id="today-capture-interruption" role="status" hidden> <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> <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> </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> <div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script> <script src="static/private-data-registry.js"></script>
@ -2259,6 +2288,7 @@
<script src="static/voice-issue-capture.js"></script> <script src="static/voice-issue-capture.js"></script>
<script src="static/voice-conversation-capture.js"></script> <script src="static/voice-conversation-capture.js"></script>
<script src="static/create-issue-sheet.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/mobile-create-issue-nav.js"></script>
<script src="static/create-and-start.js"></script> <script src="static/create-and-start.js"></script>
<script src="static/assign-and-start.js"></script> <script src="static/assign-and-start.js"></script>

View File

@ -245,6 +245,7 @@ const SHELL = [
BASE + 'static/voice-issue-capture.js', BASE + 'static/voice-issue-capture.js',
BASE + 'static/voice-conversation-capture.js', BASE + 'static/voice-conversation-capture.js',
BASE + 'static/create-issue-sheet.js', BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-pull-sheet.js',
BASE + 'static/mobile-create-issue-nav.js', BASE + 'static/mobile-create-issue-nav.js',
BASE + 'static/create-and-start.js', BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js', BASE + 'static/assign-and-start.js',

View File

@ -23,7 +23,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = { FEATURE_SOURCES = {
"comment-actions": ("static/comment-actions.js",), "comment-actions": ("static/comment-actions.js",),
"issue-capture": ( "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"), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
"push-notifications": ("static/push-notifications.js",), "push-notifications": ("static/push-notifications.js",),

View File

@ -177,6 +177,10 @@ class PullUpdateConflictError(ValueError):
"""Raised when Gitea cannot merge a pull request's base into its head.""" """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): class IssueDependencyInvalidError(ValueError):
"""Raised when a requested blocker relationship is not valid.""" """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 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 = { WORK_SEARCHES = {
"issue": ("assigned=true", "issues", None), "issue": ("assigned=true", "issues", None),
"filed": ("created=true", "issues", "created_by_me"), "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( async def create_issue(
repository: str, repository: str,
title: str, title: str,

View File

@ -1139,6 +1139,36 @@ class PullContentUpdate(BaseModel):
return value 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): 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)
@ -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") @app.get("/api/v1/background-identity")
async def background_identity() -> JSONResponse: async def background_identity() -> JSONResponse:
"""Return only the account key required to safely drain a browser outbox.""" """Return only the account key required to safely drain a browser outbox."""

View 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."
}

View 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

View File

@ -1467,6 +1467,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/voice-issue-capture.js", "/dashboard/static/voice-issue-capture.js",
"/dashboard/static/voice-conversation-capture.js", "/dashboard/static/voice-conversation-capture.js",
"/dashboard/static/create-issue-sheet.js", "/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-pull-sheet.js",
"/dashboard/static/mobile-create-issue-nav.js", "/dashboard/static/mobile-create-issue-nav.js",
"/dashboard/static/create-and-start.js", "/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js", "/dashboard/static/assign-and-start.js",