feat: prioritize actionable update triage (Closes #773)
This commit is contained in:
parent
84302d6f1e
commit
d54f58bf36
|
|
@ -1047,7 +1047,8 @@
|
||||||
onProgress: state => {
|
onProgress: state => {
|
||||||
const progress = qs('#update-triage-progress');
|
const progress = qs('#update-triage-progress');
|
||||||
progress.hidden = false;
|
progress.hidden = false;
|
||||||
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
progress.textContent = 'Update ' + state.index + ' of ' + state.total +
|
||||||
|
(state.reason ? ' · ' + state.reason : '');
|
||||||
},
|
},
|
||||||
onFinish: outcome => {
|
onFinish: outcome => {
|
||||||
notificationReader.prefetch();
|
notificationReader.prefetch();
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,19 @@ function needsAttention(item) {
|
||||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateActionability(item) {
|
||||||
|
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||||
|
if ((item?.labels || []).some(label => priorityLabels.includes(String(label).toLowerCase()))) {
|
||||||
|
return { priority: 0, reason: 'Critical' };
|
||||||
|
}
|
||||||
|
if (item?.is_review) return { priority: 1, reason: 'Review requested' };
|
||||||
|
if (item?.is_assigned && ['Overdue', 'Due today'].includes(item?.due_label)) {
|
||||||
|
return { priority: 2, reason: item.due_label };
|
||||||
|
}
|
||||||
|
if (item?.is_assigned) return { priority: 3, reason: 'Assigned to you' };
|
||||||
|
return { priority: 4, reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function buildMyWork(data, now = new Date()) {
|
function buildMyWork(data, now = new Date()) {
|
||||||
const login = data.user?.login || '';
|
const login = data.user?.login || '';
|
||||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||||
|
|
@ -82,13 +95,15 @@ function buildMyWork(data, now = new Date()) {
|
||||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||||
const existing = byKey.get(subjectKind + ':' + key);
|
const existing = byKey.get(subjectKind + ':' + key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const actionability = updateActionability(existing);
|
||||||
existing.has_update = true;
|
existing.has_update = true;
|
||||||
existing.needs_attention = true;
|
existing.needs_attention = true;
|
||||||
existing.attention_reason = 'Unread update';
|
existing.attention_reason = 'Unread update';
|
||||||
existing.notification_id = update.id;
|
existing.notification_id = update.id;
|
||||||
existing.url = update.url || existing.url;
|
existing.url = update.url || existing.url;
|
||||||
existing.updated_at = update.updated_at || existing.updated_at;
|
existing.updated_at = update.updated_at || existing.updated_at;
|
||||||
existing._priority = Math.min(existing._priority, 1);
|
existing.update_reason = actionability.reason;
|
||||||
|
existing._priority = actionability.priority;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!update.url) return;
|
if (!update.url) return;
|
||||||
|
|
@ -103,7 +118,8 @@ function buildMyWork(data, now = new Date()) {
|
||||||
attention_reason: 'Unread update',
|
attention_reason: 'Unread update',
|
||||||
notification_id: update.id,
|
notification_id: update.id,
|
||||||
reason: 'Unread update',
|
reason: 'Unread update',
|
||||||
_priority: 1,
|
update_reason: '',
|
||||||
|
_priority: 4,
|
||||||
};
|
};
|
||||||
work.push(item);
|
work.push(item);
|
||||||
byKey.set(subjectKind + ':' + key, item);
|
byKey.set(subjectKind + ':' + key, item);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,12 @@
|
||||||
state.current = identity(items[index]);
|
state.current = identity(items[index]);
|
||||||
persist();
|
persist();
|
||||||
const originalIndex = state.identities.indexOf(state.current);
|
const originalIndex = state.identities.indexOf(state.current);
|
||||||
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
|
const progress = {
|
||||||
|
index:originalIndex + 1,
|
||||||
|
total:state.identities.length,
|
||||||
|
};
|
||||||
|
if (items[index]?.update_reason) progress.reason = String(items[index].update_reason);
|
||||||
|
options.onProgress(progress);
|
||||||
options.onOpen(items[index]);
|
options.onOpen(items[index]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +84,12 @@
|
||||||
state.current = identity(next);
|
state.current = identity(next);
|
||||||
if (openNext) return openCurrent();
|
if (openNext) return openCurrent();
|
||||||
persist();
|
persist();
|
||||||
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
|
const progress = {
|
||||||
|
index:state.identities.indexOf(state.current) + 1,
|
||||||
|
total:state.identities.length,
|
||||||
|
};
|
||||||
|
if (next?.update_reason) progress.reason = String(next.update_reason);
|
||||||
|
options.onProgress(progress);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,56 @@ AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-sessi
|
||||||
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_updates_rank_actionable_work_before_newer_routine_activity():
|
||||||
|
payload = {
|
||||||
|
"user": {"login": "timmy"},
|
||||||
|
"issues": [
|
||||||
|
{"number": 1, "title": "Critical incident", "repository": "stackchain/api",
|
||||||
|
"labels": ["P0"], "assignees": [], "updated_at": "2026-08-01T10:00:00Z"},
|
||||||
|
{"number": 2, "title": "Overdue assignment", "repository": "stackchain/api",
|
||||||
|
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z",
|
||||||
|
"updated_at": "2026-08-05T10:00:00Z"},
|
||||||
|
{"number": 3, "title": "Assigned follow-up", "repository": "stackchain/api",
|
||||||
|
"labels": [], "assignees": ["timmy"], "updated_at": "2026-08-06T10:00:00Z"},
|
||||||
|
],
|
||||||
|
"pull_requests": [
|
||||||
|
{"number": 4, "title": "Requested review", "repository": "stackchain/web",
|
||||||
|
"labels": [], "assignees": [], "work_reasons": ["review_requested"],
|
||||||
|
"updated_at": "2026-08-02T10:00:00Z"},
|
||||||
|
],
|
||||||
|
"notifications": [
|
||||||
|
{"id": 11, "number": 1, "repository": "stackchain/api", "unread": True,
|
||||||
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/1"},
|
||||||
|
{"id": 12, "number": 2, "repository": "stackchain/api", "unread": True,
|
||||||
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/2"},
|
||||||
|
{"id": 13, "number": 3, "repository": "stackchain/api", "unread": True,
|
||||||
|
"subject_type": "Issue", "url": "https://forge.example/api/issues/3"},
|
||||||
|
{"id": 14, "number": 4, "repository": "stackchain/web", "unread": True,
|
||||||
|
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/4"},
|
||||||
|
{"id": 15, "number": 5, "repository": "stackchain/web", "unread": True,
|
||||||
|
"subject_type": "Issue", "title": "Newest routine update",
|
||||||
|
"updated_at": "2026-08-07T11:00:00Z", "url": "https://forge.example/web/issues/5"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const updates = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'))
|
||||||
|
.filter(item => item.has_update)
|
||||||
|
.map(item => ({{title:item.title, update_reason:item.update_reason}}));
|
||||||
|
process.stdout.write(JSON.stringify(updates));
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == [
|
||||||
|
{"title": "Critical incident", "update_reason": "Critical"},
|
||||||
|
{"title": "Requested review", "update_reason": "Review requested"},
|
||||||
|
{"title": "Overdue assignment", "update_reason": "Overdue"},
|
||||||
|
{"title": "Assigned follow-up", "update_reason": "Assigned to you"},
|
||||||
|
{"title": "Newest routine update", "update_reason": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
|
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||||
|
|
@ -4530,10 +4580,12 @@ process.stdout.write(JSON.stringify({{
|
||||||
output = json.loads(result.stdout)
|
output = json.loads(result.stdout)
|
||||||
|
|
||||||
assert len(output["queue"]) == 2
|
assert len(output["queue"]) == 2
|
||||||
assert [item["key"] for item in output["updates"]] == ["stackchain/web#8", "stackchain/api#7"]
|
assert [item["key"] for item in output["updates"]] == ["stackchain/api#7", "stackchain/web#8"]
|
||||||
assert output["updates"][0]["kind"] == "update"
|
assert output["updates"][0]["kind"] == "issue"
|
||||||
assert output["updates"][1]["kind"] == "issue"
|
assert output["updates"][0]["update_reason"] == "Assigned to you"
|
||||||
assert output["updates"][1]["url"].endswith("#issuecomment-9")
|
assert output["updates"][0]["url"].endswith("#issuecomment-9")
|
||||||
|
assert output["updates"][1]["kind"] == "update"
|
||||||
|
assert output["updates"][1]["update_reason"] == ""
|
||||||
assert output["counts"] == {
|
assert output["counts"] == {
|
||||||
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
"all": 2, "attention": 2, "issue": 1, "pull": 0, "review": 0, "update": 2
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,30 @@ def run_session(script):
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_triage_progress_exposes_the_current_actionability_reason():
|
||||||
|
result = run_session("""
|
||||||
|
const values = new Map();
|
||||||
|
const items = [
|
||||||
|
{notification_id:7, update_reason:'Review requested'},
|
||||||
|
{notification_id:8, update_reason:''},
|
||||||
|
];
|
||||||
|
const progress = [];
|
||||||
|
const session = createSession({
|
||||||
|
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||||
|
getLogin:()=> 'timmy', getItems:()=>items, onOpen:()=>{},
|
||||||
|
onProgress:value=>progress.push(value), onFinish:()=>{},
|
||||||
|
});
|
||||||
|
session.start();
|
||||||
|
session.completeAndNext();
|
||||||
|
process.stdout.write(JSON.stringify(progress));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == [
|
||||||
|
{"index": 1, "total": 2, "reason": "Review requested"},
|
||||||
|
{"index": 2, "total": 2},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
|
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
|
||||||
result = run_session("""
|
result = run_session("""
|
||||||
const values = new Map();
|
const values = new Map();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user