Select repository issue templates while filing on mobile #846
|
|
@ -108,6 +108,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||||
let pending = null;
|
let pending = null;
|
||||||
|
let issueTemplates = [];
|
||||||
|
let activeTemplate = null;
|
||||||
|
let templateRequest = 0;
|
||||||
let duplicateRequest = 0;
|
let duplicateRequest = 0;
|
||||||
let repositorySearchRequest = 0;
|
let repositorySearchRequest = 0;
|
||||||
let blockerSearchRequest = 0;
|
let blockerSearchRequest = 0;
|
||||||
|
|
@ -148,6 +151,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
labelIds: safeLabelIds(parsed.labelIds),
|
labelIds: safeLabelIds(parsed.labelIds),
|
||||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||||
};
|
};
|
||||||
|
if (typeof parsed.templateName === 'string' && parsed.templateName.trim()) {
|
||||||
|
draft.templateName = parsed.templateName.trim().slice(0, 80);
|
||||||
|
draft.templateId = String(parsed.templateId || '').slice(0, 80);
|
||||||
|
draft.capturedBody = String(parsed.capturedBody || '').slice(0, 10000);
|
||||||
|
}
|
||||||
const assignee = safeAssignee(parsed.assignee);
|
const assignee = safeAssignee(parsed.assignee);
|
||||||
if (assignee) {
|
if (assignee) {
|
||||||
draft.assignee = assignee;
|
draft.assignee = assignee;
|
||||||
|
|
@ -178,6 +186,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
body: String(draft?.body || ''),
|
body: String(draft?.body || ''),
|
||||||
labelIds: safeLabelIds(draft?.labelIds),
|
labelIds: safeLabelIds(draft?.labelIds),
|
||||||
};
|
};
|
||||||
|
if (typeof draft?.templateName === 'string' && draft.templateName.trim()) {
|
||||||
|
safe.templateName = draft.templateName.trim().slice(0, 80);
|
||||||
|
safe.templateId = String(draft.templateId || '').slice(0, 80);
|
||||||
|
safe.capturedBody = String(draft.capturedBody || '').slice(0, 10000);
|
||||||
|
}
|
||||||
const assignee = safeAssignee(draft?.assignee);
|
const assignee = safeAssignee(draft?.assignee);
|
||||||
if (assignee) {
|
if (assignee) {
|
||||||
safe.assignee = assignee;
|
safe.assignee = assignee;
|
||||||
|
|
@ -189,7 +202,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
if (dueDate) safe.dueDate = dueDate;
|
if (dueDate) safe.dueDate = dueDate;
|
||||||
const blockers = safeBlockers(draft?.blockers);
|
const blockers = safeBlockers(draft?.blockers);
|
||||||
if (blockers.length) safe.blockers = blockers;
|
if (blockers.length) safe.blockers = blockers;
|
||||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName']
|
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate', 'assignee', 'assigneeName',
|
||||||
|
'templateName', 'templateId', 'capturedBody']
|
||||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds) &&
|
||||||
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
JSON.stringify(previous.blockers || []) === JSON.stringify(safe.blockers || []);
|
||||||
|
|
@ -305,6 +319,120 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadTemplates(repository) {
|
||||||
|
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
return fetchJson('api/v1/repos/' + encoded + '/issue-templates').then(templates =>
|
||||||
|
Array.isArray(templates) ? templates.slice(0, 20) : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTemplate(draft, template, availableLabels = []) {
|
||||||
|
const source = {...(draft || {})};
|
||||||
|
const capturedBody = source.templateName ? String(source.capturedBody || '') : String(source.body || '');
|
||||||
|
if (!template) {
|
||||||
|
delete source.templateName;
|
||||||
|
delete source.templateId;
|
||||||
|
delete source.capturedBody;
|
||||||
|
return {...source, body: capturedBody};
|
||||||
|
}
|
||||||
|
const scaffold = String(template.body || '').trim().slice(0, 9000);
|
||||||
|
const body = [capturedBody.trim(), scaffold].filter(Boolean).join('\n\n---\n\n').slice(0, 10000);
|
||||||
|
const validByName = new Map((Array.isArray(availableLabels) ? availableLabels : [])
|
||||||
|
.filter(label => Number.isInteger(label?.id) && typeof label?.name === 'string')
|
||||||
|
.map(label => [label.name.toLowerCase(), label.id]));
|
||||||
|
const templateLabelIds = (Array.isArray(template.labels) ? template.labels : [])
|
||||||
|
.map(name => validByName.get(String(name).toLowerCase())).filter(Boolean);
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
title: source.title || String(template.title || '').trim().slice(0, 255),
|
||||||
|
body,
|
||||||
|
labelIds: safeLabelIds([...(source.labelIds || []), ...templateLabelIds]),
|
||||||
|
templateId: String(template.id || '').slice(0, 80),
|
||||||
|
templateName: String(template.name || 'Issue template').trim().slice(0, 80),
|
||||||
|
capturedBody,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTemplateState(draft) {
|
||||||
|
activeTemplate = draft?.templateName ? {
|
||||||
|
templateName:draft.templateName, templateId:draft.templateId,
|
||||||
|
capturedBody:draft.capturedBody || '',
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateFields(draft) {
|
||||||
|
return activeTemplate ? {...draft, ...activeTemplate} : draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreTemplate(draft, availableLabels) {
|
||||||
|
const restored = applyTemplate(templateFields(draft), null, availableLabels);
|
||||||
|
activeTemplate = null;
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTemplateOptions(repository, elements, selectedId = '') {
|
||||||
|
const request = ++templateRequest;
|
||||||
|
issueTemplates = [];
|
||||||
|
elements.select.innerHTML = '<option value="">Blank issue</option>';
|
||||||
|
elements.field.hidden = true;
|
||||||
|
if (!repository) return;
|
||||||
|
elements.status.textContent = 'Loading issue types…';
|
||||||
|
try {
|
||||||
|
const templates = await loadTemplates(repository);
|
||||||
|
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||||
|
issueTemplates = templates;
|
||||||
|
templates.forEach(template => {
|
||||||
|
const option = elements.document.createElement('option');
|
||||||
|
option.value = template.id;
|
||||||
|
option.textContent = template.name;
|
||||||
|
elements.select.appendChild(option);
|
||||||
|
});
|
||||||
|
elements.field.hidden = templates.length === 0;
|
||||||
|
elements.select.value = templates.some(template => template.id === selectedId) ? selectedId : '';
|
||||||
|
elements.status.textContent = templates.length ?
|
||||||
|
'Choose a repository guide or keep a blank issue.' : 'Blank issue selected.';
|
||||||
|
} catch (_error) {
|
||||||
|
if (request !== templateRequest || elements.getRepository() !== repository) return;
|
||||||
|
elements.status.textContent = 'Issue types could not be loaded. Blank issue filing is still available.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTemplate(identifier, draft, availableLabels) {
|
||||||
|
const template = issueTemplates.find(item => item.id === identifier) || null;
|
||||||
|
const applied = applyTemplate(templateFields(draft), template, availableLabels);
|
||||||
|
activeTemplate = template ? {
|
||||||
|
templateId:applied.templateId, templateName:applied.templateName,
|
||||||
|
capturedBody:applied.capturedBody,
|
||||||
|
} : null;
|
||||||
|
return {draft: applied, template};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindTemplatePicker(elements, callbacks) {
|
||||||
|
let labels = [];
|
||||||
|
elements.select.addEventListener('change', event => {
|
||||||
|
const {draft, template} = selectTemplate(event.target.value, callbacks.getDraft(), labels);
|
||||||
|
callbacks.setDraft(draft);
|
||||||
|
const selected = new Set(draft.labelIds || []);
|
||||||
|
elements.labelInputs().forEach(input => { input.checked = selected.has(Number(input.value)); });
|
||||||
|
elements.status.textContent = template ?
|
||||||
|
template.name + (template.about ? ' — ' + template.about : '') : 'Blank issue selected.';
|
||||||
|
callbacks.changed();
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fields:templateFields,
|
||||||
|
reset:setTemplateState,
|
||||||
|
setLabels(value) { labels = Array.isArray(value) ? value : []; },
|
||||||
|
load(repository, selectedId) {
|
||||||
|
return loadTemplateOptions(repository, elements, selectedId);
|
||||||
|
},
|
||||||
|
changeRepository(draft) {
|
||||||
|
const restored = restoreTemplate(draft, labels);
|
||||||
|
callbacks.setDraft(restored);
|
||||||
|
return restored;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function loadRepositoryPage(page) {
|
function loadRepositoryPage(page) {
|
||||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||||
|
|
@ -426,7 +554,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadRepositoryPage,
|
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadOwners, loadTemplates,
|
||||||
|
applyTemplate, setTemplateState, templateFields, restoreTemplate, loadTemplateOptions,
|
||||||
|
selectTemplate, bindTemplatePicker, loadRepositoryPage,
|
||||||
searchRepositories, searchBlockers, findDuplicates,
|
searchRepositories, searchBlockers, findDuplicates,
|
||||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||||
|
|
|
||||||
|
|
@ -520,6 +520,7 @@
|
||||||
});
|
});
|
||||||
let issueCapture = null;
|
let issueCapture = null;
|
||||||
let issueOwnerPicker = null;
|
let issueOwnerPicker = null;
|
||||||
|
let issueTemplatePicker = null;
|
||||||
let updateFollowUp = null;
|
let updateFollowUp = null;
|
||||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||||
const unfiledCaptures = createUnfiledCaptures({
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
|
|
@ -566,6 +567,7 @@
|
||||||
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
evidenceNote: qs('#issue-filing-review-evidence-note'),
|
||||||
repository: qs('#issue-filing-review-repository'),
|
repository: qs('#issue-filing-review-repository'),
|
||||||
intent: qs('#issue-filing-review-intent'),
|
intent: qs('#issue-filing-review-intent'),
|
||||||
|
issueType: qs('#issue-filing-review-template'),
|
||||||
title: qs('#issue-filing-review-title'),
|
title: qs('#issue-filing-review-title'),
|
||||||
body: qs('#issue-filing-review-body'),
|
body: qs('#issue-filing-review-body'),
|
||||||
metadata: qs('#issue-filing-review-metadata'),
|
metadata: qs('#issue-filing-review-metadata'),
|
||||||
|
|
@ -645,6 +647,16 @@
|
||||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
issueOwnerPicker = createIssueCapture.createOwnerPicker(issueCapture, document,
|
||||||
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
() => { saveIssueCaptureDraft(); updateIssueCreateActions(); });
|
||||||
|
issueTemplatePicker = issueCapture.bindTemplatePicker({
|
||||||
|
field:qs('#create-issue-template-field'), select:qs('#create-issue-template'),
|
||||||
|
status:qs('#create-issue-template-status'), document,
|
||||||
|
getRepository:()=>qs('#create-issue-repository').value,
|
||||||
|
labelInputs:()=>document.querySelectorAll('input[name="create-issue-label"]'),
|
||||||
|
}, {
|
||||||
|
getDraft:currentIssueCaptureDraft,
|
||||||
|
setDraft:draft=>{ qs('#create-issue-title').value=draft.title; qs('#create-issue-body').value=draft.body; },
|
||||||
|
changed:()=>{ saveIssueCaptureDraft(); scheduleIssueDuplicateCheck(); },
|
||||||
|
});
|
||||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||||
}
|
}
|
||||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||||
|
|
@ -3678,13 +3690,15 @@
|
||||||
let issueCaptureBlockers = [];
|
let issueCaptureBlockers = [];
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
if (issueCapture) issueCapture.saveDraft(
|
if (issueCapture) issueCapture.saveDraft(issueTemplatePicker.fields(
|
||||||
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
|
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers)
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentIssueCaptureDraft() {
|
function currentIssueCaptureDraft() {
|
||||||
return issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true);
|
return issueTemplatePicker.fields(
|
||||||
|
issueOwnerPicker.draft(selectedIssueLabelIds(), issueCaptureBlockers, true)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let issueCaptureRepositories = [];
|
let issueCaptureRepositories = [];
|
||||||
|
|
@ -3856,6 +3870,8 @@
|
||||||
status.textContent = 'Loading labels…';
|
status.textContent = 'Loading labels…';
|
||||||
try {
|
try {
|
||||||
const labels = await issueCapture.loadLabels(repository);
|
const labels = await issueCapture.loadLabels(repository);
|
||||||
|
if (qs('#create-issue-repository').value !== repository) return;
|
||||||
|
issueTemplatePicker.setLabels(labels);
|
||||||
const selected = new Set(selectedIds.map(Number));
|
const selected = new Set(selectedIds.map(Number));
|
||||||
list.innerHTML = labels.map(label =>
|
list.innerHTML = labels.map(label =>
|
||||||
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
'<label class="create-issue-label-option"><input type="checkbox" name="create-issue-label" value="' +
|
||||||
|
|
@ -3868,6 +3884,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadIssueTemplates(repository, selectedId = '') {
|
||||||
|
return issueTemplatePicker.load(repository, selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadIssueMilestones(repository, selectedId = null) {
|
async function loadIssueMilestones(repository, selectedId = null) {
|
||||||
const select = qs('#create-issue-milestone');
|
const select = qs('#create-issue-milestone');
|
||||||
const status = qs('#create-issue-milestone-status');
|
const status = qs('#create-issue-milestone-status');
|
||||||
|
|
@ -3918,6 +3938,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const captureDraft = issueCapture.loadDraft();
|
const captureDraft = issueCapture.loadDraft();
|
||||||
|
issueTemplatePicker.reset(captureDraft);
|
||||||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||||
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
||||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||||
|
|
@ -3943,7 +3964,8 @@
|
||||||
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
renderIssueCaptureBlockers(captureDraft.blockers || []);
|
||||||
qs('#create-issue-blocker-search').value = '';
|
qs('#create-issue-blocker-search').value = '';
|
||||||
qs('#create-issue-blocker-results').hidden = true;
|
qs('#create-issue-blocker-results').hidden = true;
|
||||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds).then(() =>
|
||||||
|
loadIssueTemplates(qs('#create-issue-repository').value, captureDraft.templateId));
|
||||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||||
scheduleIssueDuplicateCheck();
|
scheduleIssueDuplicateCheck();
|
||||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||||
|
|
@ -5131,8 +5153,9 @@
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
qs('#create-issue-repository').addEventListener('change', event => {
|
qs('#create-issue-repository').addEventListener('change', event => {
|
||||||
|
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
|
||||||
issueOwnerPicker.reset(event.target.value);
|
issueOwnerPicker.reset(event.target.value);
|
||||||
loadIssueLabels(event.target.value);
|
loadIssueLabels(event.target.value).then(() => loadIssueTemplates(event.target.value));
|
||||||
loadIssueMilestones(event.target.value);
|
loadIssueMilestones(event.target.value);
|
||||||
qs('#create-issue-repository-search').value = event.target.value;
|
qs('#create-issue-repository-search').value = event.target.value;
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,12 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||||
|
<label id="create-issue-template-field" for="create-issue-template" hidden>Issue type
|
||||||
|
<select id="create-issue-template" aria-describedby="create-issue-template-status">
|
||||||
|
<option value="">Blank issue</option>
|
||||||
|
</select>
|
||||||
|
<span id="create-issue-template-status" class="small" aria-live="polite">Blank issue selected.</span>
|
||||||
|
</label>
|
||||||
<label class="create-issue-owner" for="create-issue-assignee">Owner
|
<label class="create-issue-owner" for="create-issue-assignee">Owner
|
||||||
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
|
<select id="create-issue-assignee" aria-describedby="create-issue-assignee-status">
|
||||||
<option value="">Me</option>
|
<option value="">Me</option>
|
||||||
|
|
@ -876,6 +882,7 @@
|
||||||
<dl class="issue-filing-review-summary">
|
<dl class="issue-filing-review-summary">
|
||||||
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
|
<div><dt>Repository</dt><dd id="issue-filing-review-repository"></dd></div>
|
||||||
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
|
<div><dt>Action</dt><dd id="issue-filing-review-intent"></dd></div>
|
||||||
|
<div><dt>Issue type</dt><dd id="issue-filing-review-template"></dd></div>
|
||||||
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
<div><dt>Title</dt><dd id="issue-filing-review-title"></dd></div>
|
||||||
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
<div><dt>Note</dt><dd id="issue-filing-review-body"></dd></div>
|
||||||
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
<div><dt>Planning</dt><dd id="issue-filing-review-metadata"></dd></div>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
const draft = payload.draft;
|
const draft = payload.draft;
|
||||||
options.repository.textContent = draft.repository || 'No repository';
|
options.repository.textContent = draft.repository || 'No repository';
|
||||||
options.intent.textContent = INTENT_LABELS[payload.intent] || payload.intent;
|
options.intent.textContent = INTENT_LABELS[payload.intent] || payload.intent;
|
||||||
|
if (options.issueType) options.issueType.textContent = draft.templateName || 'Blank issue';
|
||||||
options.title.textContent = draft.title;
|
options.title.textContent = draft.title;
|
||||||
options.body.textContent = draft.body || 'No note provided.';
|
options.body.textContent = draft.body || 'No note provided.';
|
||||||
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
const labels = (draft.labels || []).map(label => typeof label === 'string' ? label : label.name);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/today-completion.js", "static/work-detail-position.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/assign-and-start.js", "static/queue-today.js",
|
"static/assign-and-start.js", "static/queue-today.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
|
||||||
|
|
@ -1400,6 +1400,51 @@ async def repo_labels(repository: str) -> list[dict]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def repo_issue_templates(repository: str) -> list[dict]:
|
||||||
|
"""Return a small allowlisted view of repository issue templates."""
|
||||||
|
response = await _get_client().get(
|
||||||
|
f"/api/v1/repos/{repository}/issue_templates", headers=_auth()
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if payload is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError("Gitea issue templates response was not a list")
|
||||||
|
templates = []
|
||||||
|
for item in payload:
|
||||||
|
if len(templates) >= 20:
|
||||||
|
break
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
name = name.strip()[:80]
|
||||||
|
identifier = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80]
|
||||||
|
labels = []
|
||||||
|
for label in item.get("labels", []) if isinstance(item.get("labels"), list) else []:
|
||||||
|
if not isinstance(label, str):
|
||||||
|
continue
|
||||||
|
clean = label.strip()[:80]
|
||||||
|
if clean and clean not in labels:
|
||||||
|
labels.append(clean)
|
||||||
|
if len(labels) >= 20:
|
||||||
|
break
|
||||||
|
scalar = lambda key, limit: (
|
||||||
|
item.get(key, "")[:limit] if isinstance(item.get(key), str) else ""
|
||||||
|
)
|
||||||
|
templates.append({
|
||||||
|
"id": identifier or f"template-{len(templates) + 1}",
|
||||||
|
"name": name,
|
||||||
|
"about": scalar("about", 240).strip(),
|
||||||
|
"title": scalar("title", 255),
|
||||||
|
"body": scalar("content", 9000).strip(),
|
||||||
|
"labels": labels,
|
||||||
|
})
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
async def repo_milestones(repository: str) -> list[dict]:
|
async def repo_milestones(repository: str) -> list[dict]:
|
||||||
response = await _get_client().get(
|
response = await _get_client().get(
|
||||||
f"/api/v1/repos/{repository}/milestones",
|
f"/api/v1/repos/{repository}/milestones",
|
||||||
|
|
|
||||||
21
src/main.py
21
src/main.py
|
|
@ -4528,6 +4528,27 @@ async def repository_labels(owner: str, repo: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/repos/{owner}/{repo}/issue-templates")
|
||||||
|
async def repository_issue_templates(owner: str, repo: str):
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
|
async def load_templates():
|
||||||
|
if await gitea_proxy.repository_access(repository) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Repository not found")
|
||||||
|
return await gitea_proxy.repo_issue_templates(repository)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(load_templates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Issue types could not be loaded. Blank issue creation is still available."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
|
@app.get("/api/v1/repos/{owner}/{repo}/issue-assignees")
|
||||||
async def repository_issue_assignees(owner: str, repo: str):
|
async def repository_issue_assignees(owner: str, repo: str):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,60 @@ from src import gitea_proxy, main
|
||||||
from src.idempotency import IdempotencyLedger
|
from src.idempotency import IdempotencyLedger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repository_issue_templates_are_bounded_and_normalized():
|
||||||
|
async def handler(request):
|
||||||
|
assert request.url.path == "/api/v1/repos/stackchain/api/issue_templates"
|
||||||
|
return httpx.Response(200, json=[
|
||||||
|
{
|
||||||
|
"name": " Bug report ", "about": " Reproduce a defect ",
|
||||||
|
"title": "[Bug] ", "content": "## Steps\n1. ",
|
||||||
|
"labels": ["bug", " needs-triage ", "bug", 42],
|
||||||
|
},
|
||||||
|
{"name": "", "content": "ignored"},
|
||||||
|
"malformed",
|
||||||
|
] + [{"name": f"Template {index}", "content": "x"} for index in range(30)])
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
templates = await gitea_proxy.repo_issue_templates("stackchain/api")
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert templates[0] == {
|
||||||
|
"id": "bug-report",
|
||||||
|
"name": "Bug report",
|
||||||
|
"about": "Reproduce a defect",
|
||||||
|
"title": "[Bug] ",
|
||||||
|
"body": "## Steps\n1.",
|
||||||
|
"labels": ["bug", "needs-triage"],
|
||||||
|
}
|
||||||
|
assert len(templates) == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_repository_issue_templates_route_checks_access_and_returns_blank_fallback(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def access(repository):
|
||||||
|
calls.append(("access", repository))
|
||||||
|
return {"full_name": repository}
|
||||||
|
|
||||||
|
async def templates(repository):
|
||||||
|
calls.append(("templates", repository))
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "repo_issue_templates", templates, 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/issue-templates")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
|
||||||
requests = []
|
requests = []
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,20 @@ process.stdout.write(JSON.stringify({{metadata:metadata.textContent}}));
|
||||||
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
|
assert run_node(script)["metadata"].endswith("Owner: Alex (@alex)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_names_the_repository_issue_type():
|
||||||
|
script = f"""
|
||||||
|
const createReview=require({json.dumps(str(MODULE))});
|
||||||
|
function target(){{return{{hidden:true,disabled:false,textContent:'',addEventListener:()=>{{}},replaceChildren:()=>{{}},focus:()=>{{}}}};}}
|
||||||
|
const issueType=target();
|
||||||
|
const review=createReview({{sheet:target(),confirmButton:target(),backButton:target(),evidenceList:target(),
|
||||||
|
repository:target(),intent:target(),title:target(),body:target(),metadata:target(),issueType,
|
||||||
|
document:{{createElement:()=>target(),addEventListener:()=>{{}}}},onConfirm:async()=>{{}}}});
|
||||||
|
review.open({{draft:{{repository:'o/r',title:'Crash',templateName:'Bug report'}},intent:'create-and-assign'}},target());
|
||||||
|
process.stdout.write(JSON.stringify({{issueType:issueType.textContent}}));
|
||||||
|
"""
|
||||||
|
assert run_node(script)["issueType"] == "Bug report"
|
||||||
|
|
||||||
|
|
||||||
def test_escape_returns_to_the_unchanged_issue_form():
|
def test_escape_returns_to_the_unchanged_issue_form():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createReview = require({json.dumps(str(MODULE))});
|
const createReview = require({json.dumps(str(MODULE))});
|
||||||
|
|
|
||||||
|
|
@ -4072,6 +4072,36 @@ capture.saveDraft({{repository:'o/r',title:'Delegate',body:'Context',labelIds:[]
|
||||||
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
assert json.loads(output["calls"][-1]["body"])["assignee"] == "alex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_capture_applies_and_switches_repository_templates_without_losing_authored_work():
|
||||||
|
script = f"""
|
||||||
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
const values=new Map(); const calls=[];
|
||||||
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const fetchJson=async url=>{{calls.push(url);return [{{id:'bug-report',name:'Bug report',about:'Report a defect',title:'[Bug] ',body:'## Steps\\n1.',labels:['bug','unknown']}}];}};
|
||||||
|
const capture=createIssueCapture({{storage,fetchJson}});
|
||||||
|
(async()=>{{
|
||||||
|
const templates=await capture.loadTemplates('o/r');
|
||||||
|
const labels=[{{id:3,name:'bug'}},{{id:4,name:'P1'}}];
|
||||||
|
const first=capture.applyTemplate({{repository:'o/r',title:'Camera crashes',body:'Captured on Android',labelIds:[4]}},templates[0],labels);
|
||||||
|
const second=capture.applyTemplate(first,{{id:'feature',name:'Feature',body:'## Outcome',labels:[]}},labels);
|
||||||
|
capture.saveDraft(second);
|
||||||
|
process.stdout.write(JSON.stringify({{templates,first,second,reloaded:capture.loadDraft(),calls}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
output = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == ["api/v1/repos/o/r/issue-templates"]
|
||||||
|
assert output["first"]["title"] == "Camera crashes"
|
||||||
|
assert output["first"]["body"] == "Captured on Android\n\n---\n\n## Steps\n1."
|
||||||
|
assert output["first"]["labelIds"] == [4, 3]
|
||||||
|
assert output["second"]["body"] == "Captured on Android\n\n---\n\n## Outcome"
|
||||||
|
assert output["second"]["templateName"] == "Feature"
|
||||||
|
assert output["reloaded"]["templateName"] == "Feature"
|
||||||
|
assert output["reloaded"]["capturedBody"] == "Captured on Android"
|
||||||
|
|
||||||
|
|
||||||
def test_initial_owner_picker_discards_an_older_same_repository_response():
|
def test_initial_owner_picker_discards_an_older_same_repository_response():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
const createIssueCapture=require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
|
@ -4124,6 +4154,17 @@ async def test_mobile_issue_capture_lazily_selects_an_initial_owner():
|
||||||
assert '.create-issue-owner select { min-height:44px;' in html
|
assert '.create-issue-owner select { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_issue_filing_exposes_repository_issue_types_without_blocking_blank_filing():
|
||||||
|
html = await dashboard()
|
||||||
|
assert 'id="create-issue-template"' in html
|
||||||
|
assert '<option value="">Blank issue</option>' in html
|
||||||
|
assert 'id="create-issue-template-status" class="small" aria-live="polite"' in html
|
||||||
|
assert 'issueTemplatePicker.load(repository' in html
|
||||||
|
assert 'issueCapture.bindTemplatePicker(' in html
|
||||||
|
assert 'id="issue-filing-review-template"' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user