Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
90 changes: 78 additions & 12 deletions .github/workflows/issue-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ name: Issue Triage
# exfiltrate secrets. All GitHub mutations happen in steps the LLM
# cannot influence.
#
# Runs on issue open, and again when someone removes the `needs-info` label —
# the re-triage path reads the reporter's follow-up comments and classifies +
# assigns the issue (re-adding `needs-info` only if it is still too vague).
#
# What the bot does:
# 1. Removes `needs-triage`, adds `triaged`
# 2. Classifies component — one `comp:*` label
Expand All @@ -24,12 +28,21 @@ name: Issue Triage

on:
issues:
types: [opened]
# `unlabeled` re-runs triage when someone removes `needs-info` (see the job
# `if:` below) — that removal is the signal the issue now has enough detail
# to classify and assign.
types: [opened, unlabeled]

permissions:
issues: write
contents: read

# One triage run per issue at a time; a newer event supersedes an in-flight one
# (e.g. a re-label right after open won't race with the initial run).
concurrency:
group: issue-triage-${{ github.event.issue.number }}
cancel-in-progress: true

env:
OMNIGENT_SKIP_WEB_UI: "true"
UV_INDEX_URL: https://pypi.org/simple
Expand All @@ -39,9 +52,25 @@ jobs:
triage:
runs-on: ubuntu-latest
timeout-minutes: 10
# Skip issues opened by bots to avoid feedback loops.
# Run on:
# - a newly opened issue by a non-bot author (initial triage), OR
# - the `needs-info` label being REMOVED from an open issue (re-triage:
# the removal signals the issue now has enough detail to classify).
# The `unlabeled` path intentionally allows a bot actor: the removal is made
# by the omnigent-ci App (see needs-info-response.yml) whose login ends in
# `[bot]`, and only an App-token/human removal re-triggers at all — this
# workflow's own label edits use the default GITHUB_TOKEN, which never emits
# re-triggering events, so there is no loop to guard against here.
if: >-
!endsWith(github.event.issue.user.login, '[bot]')
(
github.event.action == 'opened' &&
!endsWith(github.event.issue.user.login, '[bot]')
) ||
(
github.event.action == 'unlabeled' &&
github.event.label.name == 'needs-info' &&
github.event.issue.state == 'open'
)
steps:
- name: Check LLM credentials available
id: creds
Expand Down Expand Up @@ -107,8 +136,11 @@ jobs:
set -euo pipefail

# Fetch issue metadata to a file — never interpolated into shell.
# `comments` is included so the re-triage path (needs-info removed) can
# see the detail the reporter added in comments, not just the original
# body.
gh issue view "$ISSUE_NUMBER" --repo "$REPO" \
--json number,title,body,labels,author \
--json number,title,body,labels,author,comments \
> /tmp/issue.json

# Extract key terms for duplicate search (first 200 chars of title+body).
Expand Down Expand Up @@ -258,6 +290,22 @@ jobs:
body = (issue.get("body") or "")[:8192]
labels = [l["name"] for l in issue.get("labels", [])]

# Follow-up comments by the issue author — the reporter often supplies
# the missing detail here, so the re-triage path must read them. Only
# the author's own comments count as clarification (others' comments
# are noise for this purpose and are dropped). Capped to 4 KB total.
author_login = issue.get("author", {}).get("login")
comment_section = "None."
if author_login:
author_comments = [
c.get("body", "")
for c in issue.get("comments", [])
if c.get("author", {}).get("login") == author_login and c.get("body")
]
if author_comments:
joined = "\n\n---\n\n".join(author_comments)[:4096]
comment_section = joined

dupe_section = "None found."
if dupes:
lines = [f"- #{d['number']}: {d['title']}" for d in dupes[:5]]
Expand All @@ -275,6 +323,10 @@ jobs:
Body:
{body}

## AUTHOR FOLLOW-UP COMMENTS (UNTRUSTED — later clarification from the reporter)

{comment_section}

## CANDIDATE DUPLICATES

{dupe_section}
Expand Down Expand Up @@ -342,6 +394,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_ACTION: ${{ github.event.action }}
run: |
set -euo pipefail

Expand All @@ -350,7 +403,7 @@ jobs:
# All GitHub mutations are built in Python with proper escaping
# — no eval, no shell interpolation of model output.
python3 <<'PYEOF'
import json, pathlib, sys, shlex
import json, os, pathlib, sys, shlex

raw = pathlib.Path("/tmp/triage_output.txt").read_text()

Expand Down Expand Up @@ -391,12 +444,18 @@ jobs:
dup = None

if result.get("needs_info"):
labels_add.append("needs-info")
if "needs-info" not in existing_labels:
labels_add.append("needs-info")
if "needs-triage" in existing_labels:
labels_remove.append("needs-triage")
# needs-info issues are still triaged — they just need more info.
labels_add.append("triaged")
else:
# No longer needs info. On the re-triage path the label is already
# gone (its removal triggered this run); this is a safety net for
# any case where it lingers.
if "needs-info" in existing_labels:
labels_remove.append("needs-info")
# Type
t = result.get("type")
if t and t in ALLOWED_TYPES:
Expand All @@ -419,16 +478,22 @@ jobs:
labels_add.append("help wanted")

# Duplicate — only accept if the issue number is in our
# pre-fetched candidate list (prevents hallucinated refs).
# pre-fetched candidate list (prevents hallucinated refs). Only on
# the initial open: on re-triage we neither re-label nor re-comment
# (the duplicate call was already made at open time), so the label
# and its explanatory comment stay consistent.
dup = result.get("duplicate_of")
candidates = json.loads(
pathlib.Path("/tmp/duplicates.json").read_text()
)
candidate_numbers = {d["number"] for d in candidates}
if dup and isinstance(dup, int) and dup in candidate_numbers:
if (
dup and isinstance(dup, int) and dup in candidate_numbers
and os.environ.get("EVENT_ACTION") == "opened"
):
labels_add.append("duplicate")
else:
dup = None # discard hallucinated duplicate
dup = None # discard hallucinated / re-triage duplicate

if "needs-triage" in existing_labels:
labels_remove.append("needs-triage")
Expand Down Expand Up @@ -463,7 +528,6 @@ jobs:
pathlib.Path("/tmp/triage_result.json").write_text(json.dumps(output))

# Build a shell script with properly escaped arguments — no eval.
import os
issue = os.environ["ISSUE_NUMBER"]
repo = os.environ["REPO"]
cmds = []
Expand All @@ -477,8 +541,10 @@ jobs:
if labels_add or labels_remove:
cmds.append(" ".join(shlex.quote(a) for a in args))

# Duplicate comment.
if output["duplicate_of"]:
# Duplicate comment — only on the initial open. On the re-triage path
# (needs-info removed) any duplicate note was already posted at open
# time, so we skip it to avoid re-commenting.
if output["duplicate_of"] and os.environ.get("EVENT_ACTION") == "opened":
comment_args = [
"gh", "issue", "comment", issue, "--repo", repo,
"--body", f"Potential duplicate of #{output['duplicate_of']}. React 👎 to contest.",
Expand Down
78 changes: 78 additions & 0 deletions .github/workflows/needs-info-response.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Clear needs-info on author response

# When the issue AUTHOR comments on an issue that carries `needs-info`, remove
# the label — the reporter has (presumably) supplied the missing detail. That
# removal is the signal the rest of the pipeline is built around:
#
# author comments -> this workflow removes `needs-info`
# -> issue-triage.yml's `unlabeled` trigger re-triages
# (reads the follow-up comments, classifies + assigns,
# or re-adds `needs-info` if it is still too vague)
# author never responds -> stale.yml closes the issue after inactivity
#
# CRITICAL: the label MUST be removed with the omnigent-ci App token, not the
# default GITHUB_TOKEN. GitHub does not re-trigger workflows from events made
# by GITHUB_TOKEN, so a default-token removal would NOT fire issue-triage's
# `unlabeled` re-triage. The App token is a distinct actor, so its `unlabeled`
# event does re-trigger. If the App isn't configured, we skip (fail-closed):
# leaving the label is safer than removing it and stranding the issue.

on:
issue_comment:
types: [created]

permissions:
issues: write

concurrency:
group: needs-info-response-${{ github.event.issue.number }}
cancel-in-progress: true

jobs:
clear-needs-info:
runs-on: ubuntu-latest
# Only when a NON-bot commenter who IS the issue author comments on an OPEN
# issue (not a PR — issue_comment fires for PRs too) that still carries
# `needs-info`.
if: >-
!endsWith(github.event.sender.login, '[bot]') &&
!github.event.issue.pull_request &&
github.event.issue.state == 'open' &&
github.event.comment.user.login == github.event.issue.user.login &&
contains(github.event.issue.labels.*.name, 'needs-info')
steps:
- name: Mint omnigent-ci App token
id: app-token
if: vars.OMNIGENT_BOT_APP_ID != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}

- name: Warn when the omnigent-ci App is unconfigured
# The feature no-ops without the App (see above). Surface it so a dormant
# setup is distinguishable from a broken one.
if: steps.app-token.outputs.token == ''
run: echo "::notice::omnigent-ci App not configured; needs-info re-triage is dormant (label left in place)."

- name: Remove needs-info label
# Skip when the App isn't configured: removing with GITHUB_TOKEN would
# not re-trigger re-triage, so the label would just silently vanish.
if: steps.app-token.outputs.token != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -euo pipefail
# Re-check the live labels before removing: the job gate reads the
# (possibly stale) event payload, and `gh --remove-label` errors on a
# label that is already gone. This keeps the step idempotent under a
# race (e.g. two quick comments, or a concurrent removal).
if gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json labels \
--jq '.labels[].name' | grep -qx needs-info; then
echo "Author responded on #$ISSUE_NUMBER; removing needs-info to re-triage."
gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label needs-info
else
echo "needs-info already cleared on #$ISSUE_NUMBER; nothing to do."
fi
Loading