diff --git a/README.md b/README.md
index ccfc3f7..db19336 100644
--- a/README.md
+++ b/README.md
@@ -16,12 +16,15 @@ python3 -m pip install -r requirements.txt
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
-set or clear due dates on assigned issues, create issue comments, close assigned issues,
+list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
-creation and assignment permission. Issue capture and authored mobile actions (issue
+creation and assignment permission. The New issue sheet can optionally select an open
+repository milestone and due date; the dashboard validates both and sends them with
+self-assignment in the single create request, so planned work appears in its release
+lane immediately. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
diff --git a/frontend/create-issue-sheet.js b/frontend/create-issue-sheet.js
index d9fac54..6bc2662 100644
--- a/frontend/create-issue-sheet.js
+++ b/frontend/create-issue-sheet.js
@@ -15,18 +15,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
+ const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
+ const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
function loadStored() {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
- return {
+ const draft = {
repository: String(parsed.repository || ''),
title: String(parsed.title || ''),
body: String(parsed.body || ''),
labelIds: safeLabelIds(parsed.labelIds),
operationId: String(parsed.operationId || '').slice(0, 128),
};
+ const milestoneId = safeMilestoneId(parsed.milestoneId);
+ const dueDate = safeDueDate(parsed.dueDate);
+ if (milestoneId !== null) draft.milestoneId = milestoneId;
+ if (dueDate) draft.dueDate = dueDate;
+ return draft;
} catch (_error) {
return {...emptyDraft(), operationId: ''};
}
@@ -45,7 +52,12 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
body: String(draft?.body || ''),
labelIds: safeLabelIds(draft?.labelIds),
};
- const unchanged = ['repository', 'title', 'body'].every(key => previous[key] === safe[key]) &&
+ const milestoneId = safeMilestoneId(draft?.milestoneId);
+ const dueDate = safeDueDate(draft?.dueDate);
+ if (milestoneId !== null) safe.milestoneId = milestoneId;
+ if (dueDate) safe.dueDate = dueDate;
+ const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
+ .every(key => (previous[key] || '') === (safe[key] || '')) &&
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
return safe;
@@ -73,6 +85,13 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
+ function loadMilestones(repository) {
+ const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
+ return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
+ Array.isArray(milestones) ? milestones : []
+ );
+ }
+
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -88,6 +107,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
},
body: JSON.stringify({
title: saved.title, body: saved.body, label_ids: saved.labelIds,
+ ...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
+ ...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
}),
}).then(issue => {
clearDraft();
@@ -96,7 +117,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return pending;
}
- return { saveDraft, loadDraft, loadLabels, submit };
+ return { saveDraft, loadDraft, loadLabels, loadMilestones, submit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
diff --git a/frontend/index.html b/frontend/index.html
index ee9cece..536757e 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -206,7 +206,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-form label { display:grid; gap:6px; }
-.create-issue-form select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
+.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
@@ -519,6 +519,15 @@ textarea { resize: vertical; min-height: 120px; }
+
+