Compare commits
No commits in common. "eb88355229136ea444307ec34da2dd93d39a689f" and "33d7f4b8847ffc659638dd422ab5548601597cef" have entirely different histories.
eb88355229
...
33d7f4b884
|
|
@ -492,7 +492,6 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||||
.issue-comment-composer textarea { min-height:96px; }
|
|
||||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||||
|
|
@ -522,14 +521,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||||
#issue-sheet.read-only .issue-attachment-controls,
|
#issue-sheet.read-only .issue-comment-composer,
|
||||||
#issue-sheet.read-only .issue-attachment-preview,
|
|
||||||
#issue-sheet.read-only #issue-planning,
|
#issue-sheet.read-only #issue-planning,
|
||||||
#issue-sheet.read-only #issue-handoff,
|
#issue-sheet.read-only #issue-handoff,
|
||||||
#issue-sheet.read-only #release-issue,
|
#issue-sheet.read-only #release-issue,
|
||||||
#issue-sheet.read-only #close-issue,
|
#issue-sheet.read-only #close-issue,
|
||||||
#issue-sheet.read-only .detail-defer,
|
#issue-sheet.read-only .detail-defer,
|
||||||
#issue-sheet.read-only #issue-blockers,
|
#issue-sheet.read-only #issue-blockers,
|
||||||
|
#issue-sheet.read-only #load-older-issue-comments,
|
||||||
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
#issue-sheet.read-only #retry-issue-comment-actions { display:none; }
|
||||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||||
|
|
|
||||||
|
|
@ -3329,7 +3329,6 @@
|
||||||
qs('#load-older-issue-comments').hidden = true;
|
qs('#load-older-issue-comments').hidden = true;
|
||||||
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
|
qs('#issue-conversation-status').textContent = 'Loading newest messages…';
|
||||||
qs('#issue-comment').value = issueController.loadDraft(item);
|
qs('#issue-comment').value = issueController.loadDraft(item);
|
||||||
qs('#issue-comment-title').textContent = readOnly ? 'Add follow-up' : 'Add comment';
|
|
||||||
qs('#issue-comment-status').textContent = '';
|
qs('#issue-comment-status').textContent = '';
|
||||||
qs('#issue-handoff').open = false;
|
qs('#issue-handoff').open = false;
|
||||||
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
qs('#issue-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||||
|
|
@ -3380,8 +3379,9 @@
|
||||||
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
'Assigned to ' + detail.assignees.join(', ') : 'No assignee reported';
|
||||||
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);
|
||||||
else renderIssueConversation(issueConversation.snapshot());
|
else renderIssueConversation(issueConversation.snapshot());
|
||||||
|
if (readOnly) qs('#load-older-issue-comments').hidden = true;
|
||||||
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
qs('#open-issue-gitea').href = detail.url || item.url || '#';
|
||||||
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · follow-up enabled' :
|
qs('#issue-sheet-status').textContent = readOnly ? 'Filed issue ready · read-only' :
|
||||||
'Issue ready · ' + (detail.state || 'open');
|
'Issue ready · ' + (detail.state || 'open');
|
||||||
qs('#edit-issue-content').disabled = false;
|
qs('#edit-issue-content').disabled = false;
|
||||||
const dueDraft = issueController.loadDueDateDraft(item);
|
const dueDraft = issueController.loadDueDateDraft(item);
|
||||||
|
|
@ -5470,11 +5470,7 @@
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
qs('#issue-conversation-status').textContent = 'Loading older messages…';
|
qs('#issue-conversation-status').textContent = 'Loading older messages…';
|
||||||
try {
|
try {
|
||||||
if (issueController.readOnly(selectedIssue)) {
|
|
||||||
paintIssueConversation(await issueConversation.loadOlder(), null);
|
|
||||||
} else {
|
|
||||||
renderIssueConversation(await issueConversation.loadOlder());
|
renderIssueConversation(await issueConversation.loadOlder());
|
||||||
}
|
|
||||||
panel.scrollTop += panel.scrollHeight - previousHeight;
|
panel.scrollTop += panel.scrollHeight - previousHeight;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
qs('#issue-conversation-status').textContent = error.message + ' Loaded messages and your draft are safe; retry.';
|
||||||
|
|
@ -5707,11 +5703,7 @@
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const comment = await issueController.comment(selectedIssue, preparedBody);
|
const comment = await issueController.comment(selectedIssue, preparedBody);
|
||||||
if (issueConversation) {
|
if (issueConversation) renderIssueConversation(issueConversation.append(comment));
|
||||||
const conversation = issueConversation.append(comment);
|
|
||||||
if (issueController.readOnly(selectedIssue)) paintIssueConversation(conversation, null);
|
|
||||||
else renderIssueConversation(conversation);
|
|
||||||
}
|
|
||||||
qs('#issue-comment').value = '';
|
qs('#issue-comment').value = '';
|
||||||
issueAttachmentController.clear();
|
issueAttachmentController.clear();
|
||||||
qs('#issue-comment-status').textContent = 'Comment posted.';
|
qs('#issue-comment-status').textContent = 'Comment posted.';
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,10 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
conversation(item, initialPage) {
|
conversation(item, initialPage) {
|
||||||
const access = this.readOnly(item) ? '&access=filed' : '';
|
|
||||||
const pager = createConversationPager({
|
const pager = createConversationPager({
|
||||||
loadPage: page => fetchJson(
|
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||||
issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20' + access,
|
headers: { Accept: 'application/json' },
|
||||||
{ headers: { Accept: 'application/json' } },
|
}),
|
||||||
),
|
|
||||||
});
|
});
|
||||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||||
return pager;
|
return pager;
|
||||||
|
|
@ -268,8 +266,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||||
storage?.setItem(operationKey(item), operationId);
|
storage?.setItem(operationKey(item), operationId);
|
||||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||||
const access = this.readOnly(item) ? '?access=filed' : '';
|
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||||
commentRequest = fetchJson(issuePath(item) + '/comments' + access, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||||
body: JSON.stringify({ body }),
|
body: JSON.stringify({ body }),
|
||||||
|
|
|
||||||
16
src/main.py
16
src/main.py
|
|
@ -4852,17 +4852,11 @@ async def assigned_issue_conversation(
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
page: int | None = Query(default=None, ge=1, le=100),
|
page: int | None = Query(default=None, ge=1, le=100),
|
||||||
limit: int = Query(default=20, ge=1, le=50),
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
|
||||||
):
|
):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
async def load_conversation():
|
async def load_conversation():
|
||||||
authorized = await (
|
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||||
gitea_proxy.is_authored_issue(repository, number)
|
|
||||||
if access == "filed"
|
|
||||||
else gitea_proxy.is_assigned_issue(repository, number)
|
|
||||||
)
|
|
||||||
if not authorized:
|
|
||||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||||
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
|
return await gitea_proxy.issue_conversation_page(repository, number, page, limit)
|
||||||
|
|
||||||
|
|
@ -4887,17 +4881,11 @@ async def comment_on_assigned_issue(
|
||||||
repo: str,
|
repo: str,
|
||||||
number: int = PathParam(gt=0),
|
number: int = PathParam(gt=0),
|
||||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||||
access: Literal["assigned", "filed"] = Query(default="assigned"),
|
|
||||||
):
|
):
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
|
|
||||||
async def post_comment():
|
async def post_comment():
|
||||||
authorized = await (
|
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||||
gitea_proxy.is_authored_issue(repository, number)
|
|
||||||
if access == "filed"
|
|
||||||
else gitea_proxy.is_assigned_issue(repository, number)
|
|
||||||
)
|
|
||||||
if not authorized:
|
|
||||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||||
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
return await gitea_proxy.comment_on_issue(repository, number, comment.body)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2077,54 +2077,6 @@ async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_filed_issue_author_can_read_and_reply_but_other_issues_stay_hidden(monkeypatch):
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def authored(repository, number):
|
|
||||||
calls.append(("authored", repository, number))
|
|
||||||
return (repository, number) == ("stackchain/api", 8)
|
|
||||||
|
|
||||||
async def conversation(repository, number, page, limit):
|
|
||||||
calls.append(("conversation", repository, number, page, limit))
|
|
||||||
return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}
|
|
||||||
|
|
||||||
async def comment(repository, number, body):
|
|
||||||
calls.append(("comment", repository, number, body))
|
|
||||||
return {"id": 22, "author": "timmy", "body": body}
|
|
||||||
|
|
||||||
monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
|
|
||||||
monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
|
|
||||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
||||||
loaded = await client.get(
|
|
||||||
"/api/v1/repos/stackchain/api/issues/8/comments?access=filed&page=2&limit=20"
|
|
||||||
)
|
|
||||||
posted = await client.post(
|
|
||||||
"/api/v1/repos/stackchain/api/issues/8/comments?access=filed",
|
|
||||||
json={"body": " One clarification "},
|
|
||||||
headers={"Idempotency-Key": "filed-followup-876"},
|
|
||||||
)
|
|
||||||
hidden = await client.post(
|
|
||||||
"/api/v1/repos/private/secret/issues/8/comments?access=filed",
|
|
||||||
json={"body": "Do not post"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loaded.status_code == 200
|
|
||||||
assert loaded.json()["older_page"] == 1
|
|
||||||
assert posted.status_code == 201
|
|
||||||
assert posted.json() == {"id": 22, "author": "timmy", "body": "One clarification"}
|
|
||||||
assert hidden.status_code == 404
|
|
||||||
assert calls == [
|
|
||||||
("authored", "stackchain/api", 8),
|
|
||||||
("conversation", "stackchain/api", 8, 2, 20),
|
|
||||||
("authored", "stackchain/api", 8),
|
|
||||||
("comment", "stackchain/api", 8, "One clarification"),
|
|
||||||
("authored", "private/secret", 8),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
|
||||||
cancelled = asyncio.Event()
|
cancelled = asyncio.Event()
|
||||||
|
|
|
||||||
|
|
@ -1356,25 +1356,21 @@ const calls = [];
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
|
async def test_dashboard_opens_delegated_filings_as_read_only_issue_sheets():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
||||||
assert "const readOnly = issueController.readOnly(item);" in html
|
assert "const readOnly = issueController.readOnly(item);" in html
|
||||||
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
||||||
assert "#issue-sheet.read-only .issue-comment-composer" not in html
|
assert "#issue-sheet.read-only .issue-comment-composer" in html
|
||||||
assert "#issue-sheet.read-only .issue-attachment-controls" in html
|
|
||||||
assert "#issue-sheet.read-only #issue-planning" in html
|
assert "#issue-sheet.read-only #issue-planning" in html
|
||||||
assert "#issue-sheet.read-only #issue-handoff" in html
|
assert "#issue-sheet.read-only #issue-handoff" in html
|
||||||
assert "#issue-sheet.read-only #release-issue" in html
|
assert "#issue-sheet.read-only #release-issue" in html
|
||||||
assert "#issue-sheet.read-only #close-issue" in html
|
assert "#issue-sheet.read-only #close-issue" in html
|
||||||
assert "#issue-sheet.read-only .detail-defer" in html
|
assert "#issue-sheet.read-only .detail-defer" in html
|
||||||
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
assert "if (readOnly) paintIssueConversation(issueConversation.snapshot(), null);" in html
|
||||||
assert "if (readOnly) qs('#load-older-issue-comments').hidden = true;" not in html
|
assert "qs('#load-older-issue-comments').hidden = true;" in html
|
||||||
assert "readOnly ? 'Filed issue ready · follow-up enabled'" in html
|
assert "readOnly ? 'Filed issue ready · read-only'" in html
|
||||||
assert "paintIssueConversation(await issueConversation.loadOlder(), null)" in html
|
|
||||||
assert ".issue-comment-composer textarea" in html
|
|
||||||
assert ".issue-comment-composer button" in html
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -1643,52 +1639,6 @@ Promise.all([
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_issue_sheet_uses_author_access_for_filed_conversation_and_follow_up():
|
|
||||||
script = f"""
|
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
|
||||||
const createConversationPager = require({json.dumps(str(ISSUE_SHEET.parent / "conversation.js"))});
|
|
||||||
const calls = [];
|
|
||||||
const controller = createIssueSheet({{
|
|
||||||
fetchJson: async (url, options={{}}) => {{
|
|
||||||
calls.push({{url, method:options.method || 'GET', key:options.headers?.['Idempotency-Key'] || ''}});
|
|
||||||
if (options.method === 'POST') return {{id:22, body:'Clarifying detail'}};
|
|
||||||
return {{comments:[], page:1, older_page:null, total:0}};
|
|
||||||
}},
|
|
||||||
storage:null,
|
|
||||||
createOperationId:() => 'filed-followup-876',
|
|
||||||
createConversationPager,
|
|
||||||
}});
|
|
||||||
const filed = {{kind:'filed',repository:'stackchain/api',number:18,is_filed:true,is_assigned:false}};
|
|
||||||
const assigned = {{kind:'issue',repository:'stackchain/api',number:17,is_assigned:true}};
|
|
||||||
const initial = {{comments:[], page:2, older_page:1, total:0}};
|
|
||||||
Promise.all([
|
|
||||||
controller.conversation(filed, initial).loadOlder(),
|
|
||||||
controller.comment(filed, 'Clarifying detail'),
|
|
||||||
controller.conversation(assigned, initial).loadOlder(),
|
|
||||||
]).then(() => process.stdout.write(JSON.stringify(calls)));
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["node", "-e", script], check=True, capture_output=True, text=True
|
|
||||||
)
|
|
||||||
assert json.loads(result.stdout) == [
|
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/api/issues/18/comments?page=1&limit=20&access=filed",
|
|
||||||
"method": "GET",
|
|
||||||
"key": "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/api/issues/18/comments?access=filed",
|
|
||||||
"method": "POST",
|
|
||||||
"key": "filed-followup-876",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "api/v1/repos/stackchain/api/issues/17/comments?page=1&limit=20",
|
|
||||||
"method": "GET",
|
|
||||||
"key": "",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
def test_issue_release_is_single_flight_and_requires_confirmed_unassignment():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user