Discover and claim unassigned issues from mobile My Work #176
|
|
@ -15,10 +15,11 @@ python3 -m pip install -r requirements.txt
|
||||||
|
|
||||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
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
|
token that can read dashboard data, update the authenticated user's notification
|
||||||
threads, create and self-assign issues, create issue comments, close assigned
|
threads, create and self-assign issues, discover and claim open unassigned issues,
|
||||||
issues, inspect/comment on assigned pull requests, merge assigned pull requests, and
|
create issue comments, close assigned issues, inspect/comment on assigned pull
|
||||||
submit pull-request reviews. Pull-request replies and mobile My Work issue and PR
|
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||||
comments use Gitea's issue-comment API; mobile issue capture requires issue
|
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. Closing an assigned issue, native Comment,
|
creation and assignment permission. Closing an assigned issue, native Comment,
|
||||||
Approve, and Request changes reviews, and assigned-PR merge require repository
|
Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||||
write permission. Native Comment, Approve, and Request changes reviews support
|
write permission. Native Comment, Approve, and Request changes reviews support
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||||
.new-issue { min-height:44px; }
|
.new-issue { min-height:44px; }
|
||||||
|
.find-work-action { min-height:44px; }
|
||||||
|
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||||
|
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||||
|
.find-work-sheet.open { display:flex; }
|
||||||
|
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||||
|
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||||
|
.find-work-header button, .find-work-card button, .find-work-more { min-height:44px; }
|
||||||
|
.find-work-list { display:grid; gap:10px; }
|
||||||
|
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||||
|
.find-work-card button { width:100%; font-weight:700; }
|
||||||
|
@media(max-width:320px) { .find-work-panel { padding:12px; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||||
.create-issue-sheet.open { display:flex; }
|
.create-issue-sheet.open { display:flex; }
|
||||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||||
|
|
@ -204,7 +215,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<h2>My Work</h2>
|
<h2>My Work</h2>
|
||||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
<div class="my-work-actions">
|
||||||
|
<button class="find-work-action" id="find-work" type="button">Find work</button>
|
||||||
|
<button class="new-issue" id="new-issue" type="button">New issue</button>
|
||||||
|
</div>
|
||||||
<div class="work-filters" aria-label="Filter My Work">
|
<div class="work-filters" aria-label="Filter My Work">
|
||||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||||
|
|
@ -342,6 +356,19 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="find-work-sheet" id="find-work-sheet" role="dialog" aria-modal="true" aria-labelledby="find-work-heading">
|
||||||
|
<section class="find-work-panel">
|
||||||
|
<div class="find-work-header">
|
||||||
|
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||||
|
<button id="close-find-work" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||||
|
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||||
|
<div class="find-work-list" id="find-work-list"></div>
|
||||||
|
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||||
<section class="create-issue-panel">
|
<section class="create-issue-panel">
|
||||||
<div class="create-issue-header">
|
<div class="create-issue-header">
|
||||||
|
|
@ -494,6 +521,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
|
<script src="static/pick-work.js"></script>
|
||||||
<script src="static/issue-sheet.js"></script>
|
<script src="static/issue-sheet.js"></script>
|
||||||
<script src="static/create-issue-sheet.js"></script>
|
<script src="static/create-issue-sheet.js"></script>
|
||||||
<script src="static/pull-sheet.js"></script>
|
<script src="static/pull-sheet.js"></script>
|
||||||
|
|
@ -549,6 +577,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
let pullTrigger = null;
|
let pullTrigger = null;
|
||||||
let selectedPullDetail = null;
|
let selectedPullDetail = null;
|
||||||
let creatingIssue = false;
|
let creatingIssue = false;
|
||||||
|
let findingWork = false;
|
||||||
|
let availablePagination = { page: 1, total: 0, has_more: false };
|
||||||
let progress = null;
|
let progress = null;
|
||||||
let draft = null;
|
let draft = null;
|
||||||
let reviewFiles = [];
|
let reviewFiles = [];
|
||||||
|
|
@ -568,6 +598,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueController = createIssueSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
const pullController = createPullSheet({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
|
const findWorkController = createFindWork({
|
||||||
|
fetchJson: fetchReviewJson,
|
||||||
|
onItems: renderAvailableIssues,
|
||||||
|
onPagination: pagination => {
|
||||||
|
availablePagination = pagination;
|
||||||
|
qs('#load-more-available').hidden = !pagination.has_more;
|
||||||
|
},
|
||||||
|
onStatus: message => { qs('#find-work-status').textContent = message; },
|
||||||
|
});
|
||||||
|
|
||||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||||
|
|
@ -1098,6 +1137,63 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.map(input => Number(input.value)).filter(Number.isInteger);
|
.map(input => Number(input.value)).filter(Number.isInteger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAvailableIssues(items) {
|
||||||
|
const list = qs('#find-work-list');
|
||||||
|
list.innerHTML = items.length ? items.map((item, index) =>
|
||||||
|
'<article class="find-work-card"><div class="small">' + escapeHtml(item.repository) + '#' +
|
||||||
|
Number(item.number) + '</div><strong>' + escapeHtml(item.title || 'Untitled issue') + '</strong>' +
|
||||||
|
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
|
||||||
|
'</div><button type="button" data-claim-index="' + index + '">Assign to me</button></article>'
|
||||||
|
).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
||||||
|
list.querySelectorAll('[data-claim-index]').forEach(button => {
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const item = findWorkController.items()[Number(button.dataset.claimIndex)];
|
||||||
|
if (!item) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const confirmed = await findWorkController.claim(item);
|
||||||
|
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||||||
|
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||||
|
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||||
|
const claimed = lastMyWork.find(work =>
|
||||||
|
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
|
||||||
|
);
|
||||||
|
closeFindWorkSheet();
|
||||||
|
refreshMyWorkView();
|
||||||
|
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
||||||
|
openIssueSheet(claimed, qs('#find-work'));
|
||||||
|
} catch (error) {
|
||||||
|
qs('#find-work-status').textContent = error.message + ' Refresh and retry.';
|
||||||
|
button.disabled = false;
|
||||||
|
button.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFindWorkSheet() {
|
||||||
|
findingWork = true;
|
||||||
|
qs('#find-work-sheet').classList.add('open');
|
||||||
|
qs('#find-work-list').textContent = '';
|
||||||
|
qs('#find-work-status').textContent = 'Loading available issues…';
|
||||||
|
qs('#load-more-available').hidden = true;
|
||||||
|
qs('#close-find-work').focus();
|
||||||
|
try {
|
||||||
|
await findWorkController.load();
|
||||||
|
qs('#find-work-status').textContent = findWorkController.items().length ?
|
||||||
|
findWorkController.items().length + ' of ' + availablePagination.total + ' available issues loaded.' :
|
||||||
|
'No unassigned issues are available.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#find-work-status').textContent = error.message + ' Close and retry.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFindWorkSheet() {
|
||||||
|
findingWork = false;
|
||||||
|
qs('#find-work-sheet').classList.remove('open');
|
||||||
|
qs('#find-work').focus();
|
||||||
|
}
|
||||||
|
|
||||||
function saveIssueCaptureDraft() {
|
function saveIssueCaptureDraft() {
|
||||||
issueCapture.saveDraft({
|
issueCapture.saveDraft({
|
||||||
repository: qs('#create-issue-repository').value,
|
repository: qs('#create-issue-repository').value,
|
||||||
|
|
@ -1453,6 +1549,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
||||||
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && findingWork) {
|
||||||
|
e.preventDefault();
|
||||||
|
closeFindWorkSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Escape' && creatingIssue) {
|
if (e.key === 'Escape' && creatingIssue) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
|
|
@ -1479,6 +1580,21 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||||
|
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||||
|
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||||
|
qs('#load-more-available').addEventListener('click', async event => {
|
||||||
|
event.currentTarget.disabled = true;
|
||||||
|
qs('#find-work-status').textContent = 'Loading more available issues…';
|
||||||
|
try {
|
||||||
|
await findWorkController.loadMore();
|
||||||
|
qs('#find-work-status').textContent = findWorkController.items().length + ' of ' +
|
||||||
|
availablePagination.total + ' available issues loaded.';
|
||||||
|
} catch (error) {
|
||||||
|
qs('#find-work-status').textContent = error.message + ' Retry loading more.';
|
||||||
|
} finally {
|
||||||
|
event.currentTarget.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||||
qs('#cancel-new-issue').addEventListener('click', () => {
|
qs('#cancel-new-issue').addEventListener('click', () => {
|
||||||
saveIssueCaptureDraft();
|
saveIssueCaptureDraft();
|
||||||
|
|
|
||||||
77
frontend/pick-work.js
Normal file
77
frontend/pick-work.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||||
|
let available = [];
|
||||||
|
let pagination = { page: 1, total: 0, has_more: false };
|
||||||
|
let loadRequest = null;
|
||||||
|
let claimRequest = null;
|
||||||
|
|
||||||
|
function apply(result, append) {
|
||||||
|
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||||
|
if (append) {
|
||||||
|
const byId = new Map(available.map(item => [item.id, item]));
|
||||||
|
incoming.forEach(item => {
|
||||||
|
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
|
||||||
|
});
|
||||||
|
available = Array.from(byId.values());
|
||||||
|
} else {
|
||||||
|
available = incoming.map(item => ({ ...item }));
|
||||||
|
}
|
||||||
|
pagination = {
|
||||||
|
page: Number(result?.page) || 1,
|
||||||
|
total: Number(result?.total) || available.length,
|
||||||
|
has_more: result?.has_more === true,
|
||||||
|
};
|
||||||
|
onItems(available.slice());
|
||||||
|
onPagination({ ...pagination });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(page, append) {
|
||||||
|
if (loadRequest) return loadRequest;
|
||||||
|
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}).then(result => {
|
||||||
|
apply(result, append);
|
||||||
|
return result;
|
||||||
|
}).finally(() => { loadRequest = null; });
|
||||||
|
return loadRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset(result) {
|
||||||
|
apply(result, false);
|
||||||
|
},
|
||||||
|
load() {
|
||||||
|
return loadPage(1, false);
|
||||||
|
},
|
||||||
|
loadMore() {
|
||||||
|
if (!pagination.has_more) return Promise.resolve(false);
|
||||||
|
return loadPage(pagination.page + 1, true);
|
||||||
|
},
|
||||||
|
items() {
|
||||||
|
return available.slice();
|
||||||
|
},
|
||||||
|
claim(item) {
|
||||||
|
if (claimRequest) return claimRequest;
|
||||||
|
const key = item.repository + '#' + item.number;
|
||||||
|
onStatus('Assigning ' + key + '…');
|
||||||
|
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||||
|
'/issues/' + encodeURIComponent(item.number) + '/claim';
|
||||||
|
claimRequest = fetchJson(path, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
}).then(confirmed => {
|
||||||
|
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
|
||||||
|
throw new Error('Issue assignment was not confirmed.');
|
||||||
|
}
|
||||||
|
available = available.filter(candidate =>
|
||||||
|
candidate.repository !== item.repository || candidate.number !== item.number
|
||||||
|
);
|
||||||
|
onItems(available.slice());
|
||||||
|
onStatus('Assigned ' + key + ' to you.');
|
||||||
|
return confirmed;
|
||||||
|
}).finally(() => { claimRequest = null; });
|
||||||
|
return claimRequest;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||||
|
|
@ -38,6 +38,10 @@ class PullNotMergeableError(ValueError):
|
||||||
"""Raised before merge when current pull state or checks prohibit it."""
|
"""Raised before merge when current pull state or checks prohibit it."""
|
||||||
|
|
||||||
|
|
||||||
|
class IssueNotAvailableError(ValueError):
|
||||||
|
"""Raised before assignment when an issue is no longer open and unassigned."""
|
||||||
|
|
||||||
|
|
||||||
def _auth() -> dict[str, str]:
|
def _auth() -> dict[str, str]:
|
||||||
headers: dict[str, str] = {"Accept": "application/json"}
|
headers: dict[str, str] = {"Accept": "application/json"}
|
||||||
if GITEA_TOKEN:
|
if GITEA_TOKEN:
|
||||||
|
|
@ -175,6 +179,62 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def available_issue_page(page: int = 1, limit: int = 50) -> dict:
|
||||||
|
"""Return one bounded page of open, unassigned issues visible to the user."""
|
||||||
|
response = await _get_client().get(
|
||||||
|
"/api/v1/repos/issues/search",
|
||||||
|
headers=_auth(),
|
||||||
|
params={"state": "open", "type": "issues", "limit": limit, "page": page},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError("Gitea available issue search response was not a list")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for item in payload:
|
||||||
|
if (
|
||||||
|
not isinstance(item, dict)
|
||||||
|
or item.get("state") != "open"
|
||||||
|
or "pull_request" in item
|
||||||
|
or item.get("assignees") not in (None, [])
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
labels_value = item.get("labels")
|
||||||
|
labels = labels_value if isinstance(labels_value, list) else []
|
||||||
|
repository_value = item.get("repository")
|
||||||
|
repository = repository_value if isinstance(repository_value, dict) else {}
|
||||||
|
items.append({
|
||||||
|
"id": item.get("id"),
|
||||||
|
"number": item.get("number"),
|
||||||
|
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
|
||||||
|
"body": item.get("body", "") if isinstance(item.get("body"), str) else "",
|
||||||
|
"state": "open",
|
||||||
|
"repository": repository.get("full_name", "")
|
||||||
|
if isinstance(repository.get("full_name"), str) else "",
|
||||||
|
"labels": [
|
||||||
|
label["name"] for label in labels
|
||||||
|
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||||
|
],
|
||||||
|
"assignees": [],
|
||||||
|
"updated_at": item.get("updated_at", "")
|
||||||
|
if isinstance(item.get("updated_at"), str) else "",
|
||||||
|
"url": _safe_web_url(item.get("html_url")),
|
||||||
|
})
|
||||||
|
|
||||||
|
priority = {"p0", "priority-high", "critical"}
|
||||||
|
items.sort(key=lambda item: (item["repository"], item["number"] or 0))
|
||||||
|
items.sort(key=lambda item: item["updated_at"], reverse=True)
|
||||||
|
items.sort(key=lambda item: (
|
||||||
|
0 if any(str(label).lower() in priority for label in item["labels"]) else 1
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
total = len(items)
|
||||||
|
return {"items": items, "page": page, "total": total, "has_more": page * limit < total}
|
||||||
|
|
||||||
|
|
||||||
def _page_metadata(result: dict) -> dict:
|
def _page_metadata(result: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"page": result["page"],
|
"page": result["page"],
|
||||||
|
|
@ -508,6 +568,60 @@ async def create_issue(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def claim_available_issue(repository: str, number: int) -> dict:
|
||||||
|
issue = await fetch(f"repos/{repository}/issues/{number}")
|
||||||
|
user = await current_user()
|
||||||
|
assignees_value = issue.get("assignees") if isinstance(issue, dict) else None
|
||||||
|
if (
|
||||||
|
not isinstance(issue, dict)
|
||||||
|
or issue.get("state") != "open"
|
||||||
|
or "pull_request" in issue
|
||||||
|
or assignees_value not in (None, [])
|
||||||
|
):
|
||||||
|
raise IssueNotAvailableError("Issue is no longer available")
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(login, str) or not login:
|
||||||
|
raise ValueError("Gitea current user did not include a login")
|
||||||
|
|
||||||
|
response = await _get_client().patch(
|
||||||
|
f"/api/v1/repos/{repository}/issues/{number}",
|
||||||
|
headers=_auth(),
|
||||||
|
json={"assignee": login},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
confirmed = response.json()
|
||||||
|
if not isinstance(confirmed, dict) or confirmed.get("number") != number:
|
||||||
|
raise ValueError("Gitea did not confirm issue assignment")
|
||||||
|
confirmed_assignees_value = confirmed.get("assignees")
|
||||||
|
confirmed_assignees = (
|
||||||
|
confirmed_assignees_value if isinstance(confirmed_assignees_value, list) else []
|
||||||
|
)
|
||||||
|
logins = [
|
||||||
|
assignee["login"] for assignee in confirmed_assignees
|
||||||
|
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
|
||||||
|
]
|
||||||
|
if login not in logins:
|
||||||
|
raise ValueError("Gitea did not confirm issue assignment")
|
||||||
|
labels_value = confirmed.get("labels")
|
||||||
|
labels = labels_value if isinstance(labels_value, list) else []
|
||||||
|
return {
|
||||||
|
"id": confirmed.get("id"),
|
||||||
|
"number": number,
|
||||||
|
"title": confirmed.get("title", "")
|
||||||
|
if isinstance(confirmed.get("title"), str) else "",
|
||||||
|
"state": "open",
|
||||||
|
"repository": repository,
|
||||||
|
"labels": [
|
||||||
|
label["name"] for label in labels
|
||||||
|
if isinstance(label, dict) and isinstance(label.get("name"), str)
|
||||||
|
],
|
||||||
|
"assignees": logins,
|
||||||
|
"updated_at": confirmed.get("updated_at", "")
|
||||||
|
if isinstance(confirmed.get("updated_at"), str) else "",
|
||||||
|
"url": _safe_web_url(confirmed.get("html_url")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||||
response = await _get_client().patch(
|
response = await _get_client().patch(
|
||||||
f"/api/v1/repos/{repository}/issues/{number}",
|
f"/api/v1/repos/{repository}/issues/{number}",
|
||||||
|
|
|
||||||
41
src/main.py
41
src/main.py
|
|
@ -269,7 +269,7 @@ app.include_router(frontend_router)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live"} or request.url.path.startswith("/api/v1/work/") or (
|
if request.url.path in {"/api/v1/context", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues"} or request.url.path.startswith("/api/v1/work/") or (
|
||||||
request.url.path.startswith("/api/v1/repos/")
|
request.url.path.startswith("/api/v1/repos/")
|
||||||
and request.url.path.endswith("/review")
|
and request.url.path.endswith("/review")
|
||||||
) or request.url.path.startswith("/api/v1/notifications") or (
|
) or request.url.path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -400,6 +400,21 @@ async def paged_work(
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/available-issues")
|
||||||
|
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.available_issue_page(page), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "Available issues are temporarily unavailable. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
async def _load_context_for_user(user_data: dict) -> dict:
|
async def _load_context_for_user(user_data: dict) -> dict:
|
||||||
repo_data, issues_data, prs_data = await asyncio.gather(
|
repo_data, issues_data, prs_data = await asyncio.gather(
|
||||||
repos(), issues(), pull_requests()
|
repos(), issues(), pull_requests()
|
||||||
|
|
@ -856,6 +871,30 @@ async def reply_to_notification(
|
||||||
return JSONResponse(result, status_code=201)
|
return JSONResponse(result, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/claim")
|
||||||
|
async def claim_available_issue(
|
||||||
|
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||||
|
) -> JSONResponse:
|
||||||
|
repository = f"{owner}/{repo}"
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
gitea_proxy.claim_available_issue(repository, number),
|
||||||
|
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except gitea_proxy.IssueNotAvailableError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "The issue could not be assigned. Please retry."},
|
||||||
|
status_code=503,
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,67 @@ async def test_work_page_preserves_total_and_reason_without_loading_other_pages(
|
||||||
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
assert result["items"][0]["work_reasons"] == ["review_requested"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_available_issue_page_filters_assigned_and_pull_items_then_ranks_priority():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def upstream(request):
|
||||||
|
requests.append(str(request.url))
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"X-Total-Count": "77"},
|
||||||
|
json=[
|
||||||
|
{"id": 1, "number": 1, "title": "Ordinary", "state": "open",
|
||||||
|
"updated_at": "2026-08-07T12:00:00Z", "assignees": [],
|
||||||
|
"labels": [], "repository": {"full_name": "stackchain/api"}},
|
||||||
|
{"id": 2, "number": 2, "title": "Claimed", "state": "open",
|
||||||
|
"assignees": [{"login": "alex"}], "repository": {"full_name": "stackchain/api"}},
|
||||||
|
{"id": 3, "number": 3, "title": "A pull", "state": "open",
|
||||||
|
"assignees": [], "pull_request": {"merged": False},
|
||||||
|
"repository": {"full_name": "stackchain/api"}},
|
||||||
|
{"id": 4, "number": 4, "title": "Critical", "state": "open",
|
||||||
|
"updated_at": "2026-08-07T10:00:00Z", "assignees": [],
|
||||||
|
"labels": [{"name": "critical"}],
|
||||||
|
"repository": {"full_name": "stackchain/web"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.available_issue_page(page=2)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert requests == [
|
||||||
|
"http://127.0.0.1:3000/api/v1/repos/issues/search?state=open&type=issues&limit=50&page=2"
|
||||||
|
]
|
||||||
|
assert [item["title"] for item in result["items"]] == ["Critical", "Ordinary"]
|
||||||
|
assert result == {
|
||||||
|
"items": result["items"], "page": 2, "total": 77, "has_more": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def available(page):
|
||||||
|
calls.append(page)
|
||||||
|
return {"items": [{"number": 7}], "page": page, "total": 51, "has_more": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "available_issue_page", available)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get("/api/v1/available-issues?page=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == {
|
||||||
|
"items": [{"number": 7}], "page": 1, "total": 51, "has_more": True,
|
||||||
|
}
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
async def test_initial_work_collections_expose_independent_pagination(monkeypatch):
|
||||||
async def fake_page(stream, page=1, limit=50):
|
async def fake_page(stream, page=1, limit=50):
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,82 @@ async def test_gitea_create_issue_requires_confirmed_self_assignment():
|
||||||
await gitea_proxy.stop_client()
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
if request.method == "GET" and request.url.path.endswith("/issues/17"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||||
|
"assignees": [], "labels": [],
|
||||||
|
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
})
|
||||||
|
if request.method == "GET" and request.url.path == "/api/v1/user":
|
||||||
|
return httpx.Response(200, json={"login": "timmy"})
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": 81, "number": 17, "title": "Available", "state": "open",
|
||||||
|
"assignees": [{"login": "timmy"}], "labels": [],
|
||||||
|
"html_url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
})
|
||||||
|
|
||||||
|
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||||
|
try:
|
||||||
|
result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
|
||||||
|
finally:
|
||||||
|
await gitea_proxy.stop_client()
|
||||||
|
|
||||||
|
assert [(request.method, request.url.path) for request in requests] == [
|
||||||
|
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||||
|
("GET", "/api/v1/user"),
|
||||||
|
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||||
|
]
|
||||||
|
assert requests[2].content == b'{"assignee":"timmy"}'
|
||||||
|
assert result["repository"] == "stackchain/api"
|
||||||
|
assert result["assignees"] == ["timmy"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def claim(repository, number):
|
||||||
|
calls.append((repository, number))
|
||||||
|
return {
|
||||||
|
"id": 81, "number": number, "title": "Available", "state": "open",
|
||||||
|
"repository": repository, "labels": [], "assignees": ["timmy"],
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/17",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json()["assignees"] == ["timmy"]
|
||||||
|
assert calls == [("stackchain/api", 17)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
|
||||||
|
async def claim(_repository, _number):
|
||||||
|
raise gitea_proxy.IssueNotAvailableError("claimed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
assert response.json() == {
|
||||||
|
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||||
async def assigned(repository, number):
|
async def assigned(repository, number):
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||||
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||||
|
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||||
|
|
||||||
|
|
||||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||||
|
|
@ -237,6 +238,85 @@ pager.loadMore('issue', [{{id:1}}]).then(result =>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_work_claim_is_single_flight_and_removes_only_confirmed_issue():
|
||||||
|
script = f"""
|
||||||
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
||||||
|
let calls = 0;
|
||||||
|
let release;
|
||||||
|
const states = [];
|
||||||
|
const statuses = [];
|
||||||
|
const controller = createFindWork({{
|
||||||
|
fetchJson: (url, options) => {{
|
||||||
|
calls += 1;
|
||||||
|
return new Promise(resolve => {{ release = () => resolve({{
|
||||||
|
id:17,number:7,title:'Available',repository:'stackchain/api',
|
||||||
|
state:'open',labels:[],assignees:['timmy'],url:'https://forge.example/issues/7'
|
||||||
|
}}); }});
|
||||||
|
}},
|
||||||
|
onItems: items => states.push(items),
|
||||||
|
onPagination: () => {{}},
|
||||||
|
onStatus: status => statuses.push(status),
|
||||||
|
}});
|
||||||
|
controller.reset({{items:[
|
||||||
|
{{id:17,number:7,title:'Available',repository:'stackchain/api'}},
|
||||||
|
{{id:18,number:8,title:'Other',repository:'stackchain/web'}}
|
||||||
|
],page:1,total:2,has_more:false}});
|
||||||
|
const item = controller.items()[0];
|
||||||
|
const first = controller.claim(item);
|
||||||
|
const duplicate = controller.claim(item);
|
||||||
|
release();
|
||||||
|
Promise.all([first,duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||||
|
calls,states,statuses,results,remaining:controller.items()
|
||||||
|
}})));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == 1
|
||||||
|
assert [item["number"] for item in output["remaining"]] == [8]
|
||||||
|
assert [item["number"] for item in output["states"][-1]] == [8]
|
||||||
|
assert output["statuses"] == ["Assigning stackchain/api#7…", "Assigned stackchain/api#7 to you."]
|
||||||
|
assert output["results"][0]["assignees"] == ["timmy"]
|
||||||
|
assert output["results"][1]["assignees"] == ["timmy"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_work_loads_paginated_results_without_duplicates():
|
||||||
|
script = f"""
|
||||||
|
const createFindWork = require({json.dumps(str(PICK_WORK))});
|
||||||
|
const calls = [];
|
||||||
|
const states = [];
|
||||||
|
const pages = [];
|
||||||
|
const controller = createFindWork({{
|
||||||
|
fetchJson: url => {{
|
||||||
|
calls.push(url);
|
||||||
|
const page = Number(new URL(url, 'https://example.test/').searchParams.get('page'));
|
||||||
|
return Promise.resolve(page === 1 ? {{
|
||||||
|
items:[{{id:1,number:1,repository:'stackchain/api'}}],page:1,total:2,has_more:true
|
||||||
|
}} : {{
|
||||||
|
items:[{{id:1,number:1,repository:'stackchain/api'}},{{id:2,number:2,repository:'stackchain/web'}}],
|
||||||
|
page:2,total:2,has_more:false
|
||||||
|
}});
|
||||||
|
}},
|
||||||
|
onItems: items => states.push(items),
|
||||||
|
onPagination: page => pages.push(page),
|
||||||
|
onStatus: () => {{}},
|
||||||
|
}});
|
||||||
|
controller.load().then(() => controller.loadMore()).then(() =>
|
||||||
|
process.stdout.write(JSON.stringify({{calls,states,pages,items:controller.items()}}))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
|
||||||
|
assert [item["id"] for item in output["items"]] == [1, 2]
|
||||||
|
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
@ -247,6 +327,21 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||||
assert '.load-more-work { min-height:44px;' in html
|
assert '.load-more-work { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_find_work_sheet_is_accessible_touch_sized_and_subpath_safe():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="find-work"' in html
|
||||||
|
assert 'id="find-work-sheet" role="dialog" aria-modal="true"' in html
|
||||||
|
assert 'id="find-work-list"' in html
|
||||||
|
assert 'id="find-work-status" class="small" aria-live="assertive"' in html
|
||||||
|
assert 'id="load-more-available"' in html
|
||||||
|
assert 'static/pick-work.js' in html
|
||||||
|
assert '.find-work-action { min-height:44px;' in html
|
||||||
|
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in html
|
||||||
|
assert '@media(max-width:320px)' in html
|
||||||
|
|
||||||
|
|
||||||
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
def test_issue_capture_is_single_flight_and_keeps_draft_until_confirmed_success():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user