Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/a2ui-issue-triage/assets/triage_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ <h3 class="triage-pane-title">Triage Decisions</h3>
<select id="triageAction" onchange="updateActiveDecision('action', this.value)">
<option value="investigate">Keep open, mark for research</option>
<option value="assign_and_fix">Keep open, assign to developer</option>
<option value="needs_info">Ask for info, apply waiting-for-user-response</option>
<option value="needs_info">Ask for info, apply waiting-for-author-response</option>
<option value="close_duplicate">Close as duplicate</option>
<option value="close_invalid">Close as invalid/spam</option>
<option value="close_resolved">Close as resolved/answered</option>
Expand Down Expand Up @@ -1444,7 +1444,7 @@ <h2 class="detail-title">${issue.title}</h2>
const actionLabels = {
investigate: 'Keep open, mark for research',
assign_and_fix: 'Keep open, assign to developer',
needs_info: 'Ask for info, waiting-for-user-response',
needs_info: 'Ask for info, waiting-for-author-response',
close_duplicate: 'Close as duplicate',
close_invalid: 'Close as invalid/spam',
close_resolved: 'Close as resolved/answered',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ _Note: There is no P4 priority tier. Issues that are out of scope, highly custom
- **Reproduction**: Do not attempt local reproduction unless the steps are simple, clear, and the environment can be set up immediately (e.g., inspecting a file, executing a standard unit test). If you need to check out the branch or clone the PR repo to reproduce the issue, do it in a temporary clone or git worktree (e.g. in `<appDataDir>/brain/<conversation-id>/scratch/issue_12345_repro/`). This keeps the main working repository clean of temporary test files or build side effects.
- **Static Analysis**: For complex bugs, analyze the logs, stack traces, and relevant specification files (e.g., JSON schemas in `specification/`) to diagnose the issue, but don't spend a bunch of resources setting up a complex reproduction.
- **Action**:
- If reproduction steps or logs are missing and prevent diagnosis, set action to `needs_info`, draft a response asking for the missing details, and apply the `waiting-for-user-response` label.
- If reproduction steps or logs are missing and prevent diagnosis, set action to `needs_info`, draft a response asking for the missing details, and apply the `waiting-for-author-response` label.
- If the bug is verified, suggest the appropriate priority (`P0` to `P3`) and recommend the owner of the affected component based on path mapping (e.g. paths with `renderers/lit` belong to the Lit renderer maintainer, `specification/` to the specification maintainer) and file commit history (`git log -n 5 --format="%ae" <file>`). Do not rely only on raw `git blame`, as formatting or linter changes can skew authorship.
- Clean up any temporary files, worktrees, clones, and/or temporary branches when finished.

Expand Down Expand Up @@ -74,7 +74,7 @@ Examples:
> 1. [Specific missing detail, e.g., browser console logs / the exact protocol payload]
> 2. [Simplified reproduction steps or a minimal schema sample]
>
> Marked as `waiting-for-user-response` for now.
> Marked as `waiting-for-author-response` for now.

### Duplicate Issues

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/a2ui-issue-triage/scripts/suggest_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,10 @@ def guess_triage_heuristics(title, body):
" diagnose this."
)

# Guess waiting-for-user-response if repro steps are obviously missing in bug reports
# Guess waiting-for-author-response if repro steps are obviously missing in bug reports
if "type: bug" in labels:
if not any(w in text for w in ["reproduce", "repro", "steps", "run", "how to"]):
labels.append("waiting-for-user-response")
labels.append("waiting-for-author-response")
action = "needs_info"
reply = (
"Please provide a minimal reproduction schema or code snippet to help"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_guess_triage_heuristics_crash(self):
self.assertEqual(res["priority"], "P2")
self.assertEqual(res["action"], "needs_info")
self.assertIn("type: bug", res["labels"])
self.assertIn("waiting-for-user-response", res["labels"])
self.assertIn("waiting-for-author-response", res["labels"])

def test_guess_triage_heuristics_needs_info(self):
# Feature request
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/web_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
renderers: ${{ steps.filter.outputs.renderers }}
tools: ${{ steps.filter.outputs.tools }}
samples: ${{ steps.filter.outputs.samples }}
scripts: ${{ steps.filter.outputs.scripts }}
other: ${{ steps.filter.outputs.other }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
Expand Down Expand Up @@ -65,6 +66,10 @@ jobs:
- 'yarn.lock'
- '.github/workflows/web_ci.yml'
- '.github/actions/setup-node/**'
scripts:
- 'scripts/**'
- '.github/workflows/web_ci.yml'
- '.github/actions/setup-node/**'
other:
- 'samples/personalized_learning/**'
- 'specification/**'
Expand Down Expand Up @@ -133,6 +138,22 @@ jobs:
- name: Test Samples
run: eval "yarn workspaces foreach -A $SAMPLES_INC -p run test"

# Repo automation under scripts/ (e.g. the triage bot) runs on plain Node with
# no workspace deps, so it only needs the built-in test runner.
ci-scripts:
needs: changes
if: ${{ needs.changes.outputs.scripts == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-node
with:
install: "false"
- name: Test Scripts
run: node --test scripts/*.test.mjs

ci-other:
needs: changes
if: ${{ needs.changes.outputs.other == 'true' }}
Expand Down
4 changes: 3 additions & 1 deletion docs/contributing/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ This section describes goals at a high level. See concrete steps in the section
3. status: needs-triage
4. status: first-line-handled
5. size: small
6. status: waiting-for-user-response
6. status: waiting-for-author-response

See [all github labels](https://github.com/a2ui-project/a2ui/labels).

Two of these are automated by [scripts/triage.mjs](../../scripts/triage.mjs): `status: needs-triage` is added and removed entirely by the bot, and `status: waiting-for-author-response` is applied by hand — while it is set the item stays out of the triage queue, and the bot removes it once the author replies.

## Triage responsibilities

### First line triage
Expand Down
153 changes: 98 additions & 55 deletions scripts/triage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@
* limitations under the License.
*/

// Reconciles the 'status: needs-triage' label across all open issues and PRs. The label
// is fully owned by this automation: it is added to every item that matches a
// rule below and removed from every item that does not, on each run.
// Reconciles the 'status: needs-triage' and 'status: waiting-for-author-response'
// labels across all open issues and PRs.
//
// An item is flagged when:
// 1. It is an issue without 'status: waiting-for-user-response' that is:
// 'status: needs-triage' is fully owned by this automation: it is added to every
// item that matches a rule below and removed from every item that does not, on
// each run.
//
// 'status: waiting-for-author-response' is applied by hand; the automation only
// clears it, once the author has replied. While it is set the item is never
// flagged, so parking an item on its author also parks it out of triage.
//
// An item is flagged with 'status: needs-triage' when it does not have the label
// 'status: waiting-for-author-response' and:
// 1. It is an issue:
// a. without a priority label, or
// b. P0/P1 without an assignee, or
// c. P0 and stale for more than 1 day, or
Expand All @@ -43,7 +51,7 @@
// history of runs see:
// https://github.com/a2ui-project/a2ui/actions/workflows/triage.yml

export const WAITING_LABEL = 'status: waiting-for-user-response';
export const WAITING_LABEL = 'status: waiting-for-author-response';
export const FLAG_LABEL = 'status: needs-triage';
export const PRIORITY_LABELS = ['P0', 'P1', 'P2', 'P3', 'P4'];

Expand Down Expand Up @@ -74,40 +82,68 @@ const labelNames = item =>
const ageInDays = (isoTimestamp, now) => (now - new Date(isoTimestamp).getTime()) / DAY_MS;

/**
* Returns the most recent human contribution to an item: either its newest
* non-bot contribution (a comment, or — on PRs — a review or inline review
* comment), or, if there are none, the opening post itself. Used both to measure
* staleness and to decide whether an external author is still awaiting a
* maintainer response.
* Returns the newest non-bot contribution (a comment, or — on PRs — a review or
* inline review comment), or null if the item has none.
*
* `contributions` is the merged, normalized event list from `fetchContributions`.
* On PRs it comes from three different endpoints so it is not sorted; the scan
* picks the latest regardless of order.
*/
export function lastHumanContribution(item, contributions) {
let latest = {
createdAt: item.created_at,
association: item.author_association,
user: item.user,
};

function newestHumanContribution(contributions) {
let newest = null;
for (const event of contributions) {
if (isBot(event.user)) continue;
if (event.createdAt >= latest.createdAt) {
latest = event;
if (!newest || event.createdAt >= newest.createdAt) {
newest = event;
}
}
return newest;
}

/**
* Returns the most recent human contribution to an item, falling back to the
* opening post when it has none. Used both to measure staleness and to decide
* whether an external author is still awaiting a maintainer response.
*/
export function lastHumanContribution(item, contributions) {
return (
newestHumanContribution(contributions) ?? {
createdAt: item.created_at,
association: item.author_association,
user: item.user,
}
);
}

return latest;
/**
* True when the author has spoken since opening the item — the newest human
* contribution is theirs. Used to clear WAITING_LABEL. The opening post does not
* count: an item with no replies is exactly the one still waiting on its author.
*
* A maintainer who replies after the author hides that response from this check;
* such an item is covered by the staleness rules instead, and the label can
* always be removed by hand.
*/
export function authorHasResponded(item, contributions) {
const author = item.user?.login;
const newest = newestHumanContribution(contributions);
return Boolean(author) && newest?.user?.login === author;
}

/**
* Returns a human-readable reason why a single open item should carry the flag
* label, or null if it should not. The reason is logged for visibility.
*/
export function flagReason(item, contributions, now) {
const isPR = Boolean(item.pull_request);
const labels = labelNames(item);

// An item parked on its author is off the triage queue entirely, whatever the
// rules below would say.
if (labels.includes(WAITING_LABEL)) {
return null;
}

const isPR = Boolean(item.pull_request);
const latest = lastHumanContribution(item, contributions);
const staleDays = ageInDays(latest.createdAt, now);

Expand All @@ -133,10 +169,7 @@ export function flagReason(item, contributions, now) {
return `the latest reply is from an external contributor and has gone unanswered for more than ${EXTERNAL_RESPONSE_DAYS} day.`;
}

// Rule 1: issues, excluding those parked on the user's response.
if (labels.includes(WAITING_LABEL)) {
return null;
}
// Rule 1: issues.

const priority = PRIORITY_LABELS.find(p => labels.includes(p));

Expand Down Expand Up @@ -249,47 +282,57 @@ export default async function issueTriage({github, context}) {
}));

// Decide each item's desired state from the snapshot, and keep only those
// whose label needs to change. The snapshot from `listForRepo` can be stale
// whose labels need to change. The snapshot from `listForRepo` can be stale
// if another run (the daily schedule overlapping an issue event) already
// changed the label, so the actual mutation re-checks the live state below.
// changed a label, so the actual mutation re-checks the live state below.
const itemsToUpdate = itemsWithContributions
.map(({item, contributions}) => ({item, reason: flagReason(item, contributions, now)}))
.filter(({item, reason}) => Boolean(reason) !== labelNames(item).includes(FLAG_LABEL));
.map(({item, contributions}) => {
const labels = labelNames(item);
const clearWaiting =
labels.includes(WAITING_LABEL) && authorHasResponded(item, contributions);

// Score the item as it will look once the waiting label is gone: a label
// this run clears must not also inhibit flagging until the next run.
const scored = clearWaiting
? {...item, labels: labels.filter(label => label !== WAITING_LABEL)}
: item;

return {item, clearWaiting, reason: flagReason(scored, contributions, now)};
})
.filter(
({item, clearWaiting, reason}) =>
clearWaiting || Boolean(reason) !== labelNames(item).includes(FLAG_LABEL),
);

let added = 0;
let removed = 0;
let waitingCleared = 0;

await mapInBatches(itemsToUpdate, async ({item, reason}) => {
await mapInBatches(itemsToUpdate, async ({item, clearWaiting, reason}) => {
const target = {owner, repo, issue_number: item.number};
const wantsFlag = Boolean(reason);
try {
// Re-read the live labels so a concurrent run cannot make us add the
// label twice.
const {data: fresh} = await github.rest.issues.get({
owner,
repo,
issue_number: item.number,
});
const hasFlag = labelNames(fresh).includes(FLAG_LABEL);
if (wantsFlag === hasFlag) {
return; // Another run already reconciled this item.
// Re-read the live labels so a concurrent run cannot make us add or remove
// a label twice.
const {data: fresh} = await github.rest.issues.get(target);
const freshLabels = labelNames(fresh);

if (clearWaiting && freshLabels.includes(WAITING_LABEL)) {
await github.rest.issues.removeLabel({...target, name: WAITING_LABEL});
waitingCleared += 1;
console.log(`Cleared ${WAITING_LABEL} on ${item.html_url} — the author responded.`);
}

if (wantsFlag === freshLabels.includes(FLAG_LABEL)) {
return; // Another run already reconciled the flag.
}

if (wantsFlag) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: item.number,
labels: [FLAG_LABEL],
});
await github.rest.issues.addLabels({...target, labels: [FLAG_LABEL]});
added += 1;
console.log(`Flagged ${item.html_url} — ${reason}`);
} else {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: item.number,
name: FLAG_LABEL,
});
await github.rest.issues.removeLabel({...target, name: FLAG_LABEL});
removed += 1;
console.log(`Unflagged ${item.html_url} — no longer matches any triage rule.`);
}
Expand All @@ -299,7 +342,7 @@ export default async function issueTriage({github, context}) {
});

console.log(
`A2UI triage-flag reconciliation completed: ` +
`${openItems.length} items, +${added} / -${removed} label changes`,
`A2UI triage-flag reconciliation completed: ${openItems.length} items, ` +
`+${added} / -${removed} '${FLAG_LABEL}', -${waitingCleared} '${WAITING_LABEL}'`,
);
}
Loading
Loading