stackchain-dashboard/frontend/create-pull-sheet.js
timmy e5b9833096
All checks were successful
CI / lint (pull_request) Successful in 3m19s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 5m16s
feat: create mobile pull requests from pushed branches (Closes #1362)
2026-08-24 19:31:16 +00:00

204 lines
7.5 KiB
JavaScript

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