Triage: AI #8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "Triage: AI" | |
| # AI-assisted issue triage using GitHub Models (actions/ai-inference). | |
| # On a new issue it asks a model to classify the issue against the AmbleKit | |
| # label taxonomy, then applies labels, sets the issue type, sets the org | |
| # Priority/Effort fields (best effort), and leaves a triage comment that | |
| # includes a likely-cause (bugs) / recommended-implementation (features) note. | |
| # If the model output can't be parsed it falls back to "S: Untriaged". | |
| on: | |
| issues: | |
| types: [opened, reopened] | |
| workflow_dispatch: | |
| inputs: | |
| issue: | |
| description: "Issue number to (re)triage" | |
| required: true | |
| permissions: | |
| issues: write | |
| models: read | |
| contents: read | |
| concurrency: | |
| # key off trusted contexts only - issue number for issue events, run_id for | |
| # manual runs (the dispatch input is user-controlled, don't put it in the key) | |
| group: triage-${{ github.event.issue.number || github.run_id }} | |
| cancel-in-progress: true | |
| jobs: | |
| triage: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Resolve issue number | |
| id: ctx | |
| env: | |
| NUM: ${{ github.event.issue.number || github.event.inputs.issue }} | |
| run: | | |
| # NUM can come from a user-controlled workflow_dispatch input - require | |
| # digits only so it can't inject newlines into $GITHUB_OUTPUT. | |
| if ! printf '%s' "$NUM" | grep -qE '^[0-9]+$'; then | |
| echo "invalid issue number: $NUM" >&2; exit 1 | |
| fi | |
| echo "num=$NUM" >> "$GITHUB_OUTPUT" | |
| - name: Fetch issue (for manual runs) + build prompt | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| NUM: ${{ steps.ctx.outputs.num }} | |
| EVENT_TITLE: ${{ github.event.issue.title }} | |
| EVENT_BODY: ${{ github.event.issue.body }} | |
| run: | | |
| set -euo pipefail | |
| if [ -n "${EVENT_TITLE:-}" ]; then | |
| TITLE="$EVENT_TITLE"; BODY="$EVENT_BODY" | |
| else | |
| TITLE=$(gh issue view "$NUM" --repo "$REPO" --json title --jq '.title') | |
| BODY=$(gh issue view "$NUM" --repo "$REPO" --json body --jq '.body') | |
| fi | |
| { | |
| echo "Issue #$NUM" | |
| echo "Title: $TITLE" | |
| echo | |
| echo "Body:" | |
| echo "${BODY:-(no body)}" | |
| } > "$RUNNER_TEMP/triage-prompt.txt" | |
| - name: AI classify | |
| id: ai | |
| # don't fail the run if GitHub Models is unavailable (not enabled / rate-limited | |
| # / 403) - the Apply step falls back to S: Untriaged when the response is empty. | |
| continue-on-error: true | |
| uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1 | |
| with: | |
| model: openai/gpt-4o-mini | |
| max-tokens: 800 | |
| prompt-file: ${{ runner.temp }}/triage-prompt.txt | |
| system-prompt: | | |
| You triage GitHub issues for Timeless Heroes - a Fabric 1.20.1 Minecraft | |
| *content* mod (Java, mod id "timeless") that adds superhero suits with | |
| custom powers and abilities. | |
| Architecture you can reason about for the "insight" field: | |
| - Suits live in suit/ (Iron Man marks mk2/3/5/7/50/85, Spider-Man, Batman, | |
| Moon Knight, Superman). A Suit owns a PowerList; a "set" equips the four | |
| armour pieces. Per-player state is NBT via SuitItem.Data (auto-synced). | |
| - Powers live in power/impl/, registered in PowerRegistry, identified by | |
| Identifier. They have run/tick(server), tick(client), and client hooks | |
| (handlesRightClick/LeftClick, applyPose). 9 keybinds send UsePowerC2SPacket. | |
| - Rendering: SuitFeature draws the suit model over the player; SuitModel | |
| dispatches power poses; nano assembly uses a custom core shader. Animation | |
| system under mc/duzo/animation. Jarvis-style HUD in client/gui. | |
| - Networking is c2s/s2c FabricPacket records in network/. Datagen in datagen/. | |
| Classify the issue and reply with ONE LINE of minified JSON, no code | |
| fences, no prose. Schema (use EXACTLY these allowed values): | |
| { | |
| "type": "Bug" | "Feature" | "Task", | |
| "priority": "Urgent" | "High" | "Medium" | "Low", | |
| "effort": "High" | "Medium" | "Low", | |
| "difficulty": "DB" | "D3" | "D2" | "D1" | "D0", | |
| "areas": [ subset of: "A: Suits","A: Powers","A: Entities","A: Datagen","A: Admin Tooling","A: Core Tech","A: Build","A: Docs" ], | |
| "changes": [ subset of: "C: Render","C: UI","C: Networking","C: Textures","C: Audio","C: Translations","C: Structures","C: No Java" ], | |
| "summary": "one short lowercase sentence", | |
| "insight": "for a Bug: 2-4 sentences on the most likely cause, naming the suspect subsystem/class/package; for a Feature: 2-4 sentences on a recommended implementation approach, pointing at where in the codebase it would go; for a Task: empty string", | |
| "needs_info": true | false | |
| } | |
| Guidance: crashes / data loss -> priority High or Urgent. A vague report | |
| with no repro/logs -> needs_info=true. "Task" = docs/chores/proposals, | |
| "Feature" = new suit/power/content, "Bug" = something broken. difficulty | |
| is how much codebase knowledge a fix needs (DB beginner .. D0 extreme). | |
| Pick the 1-2 most relevant areas; do not invent labels outside the lists. | |
| Keep "insight" concrete but clearly a best guess - a maintainer confirms it. | |
| - name: Apply triage | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| NUM: ${{ steps.ctx.outputs.num }} | |
| RESP: ${{ steps.ai.outputs.response }} | |
| # optional overrides - set as repo/org Actions variables instead of editing | |
| # this file. find the ids via: gh api orgs/<org>/issue-fields | |
| PRIORITY_FIELD_ID: ${{ vars.PRIORITY_FIELD_ID }} | |
| EFFORT_FIELD_ID: ${{ vars.EFFORT_FIELD_ID }} | |
| run: | | |
| set -uo pipefail | |
| # prefer the whole response if it already parses as json (expected case: | |
| # the model is asked for one line of minified json). otherwise strip any | |
| # code fences and fall back to the first {...} block. | |
| if printf '%s' "$RESP" | jq -e . >/dev/null 2>&1; then | |
| JSON="$RESP" | |
| else | |
| JSON=$(printf '%s' "$RESP" | tr -d '\r\n' | sed 's/```json//g; s/```//g' | grep -o '{.*}' | head -1 || true) | |
| fi | |
| echo "model json: $JSON" | |
| if [ -z "$JSON" ] || ! printf '%s' "$JSON" | jq -e . >/dev/null 2>&1; then | |
| echo "no valid json -> S: Untriaged" | |
| gh issue edit "$NUM" --repo "$REPO" --add-label "S: Untriaged" || true | |
| exit 0 | |
| fi | |
| # a previous run may have left S: Untriaged - clear it now that we have a | |
| # valid classification so the issue isn't both triaged and untriaged. | |
| gh issue edit "$NUM" --repo "$REPO" --remove-label "S: Untriaged" >/dev/null 2>&1 || true | |
| TYPE=$(echo "$JSON" | jq -r '.type // empty') | |
| PRIORITY=$(echo "$JSON" | jq -r '.priority // empty') | |
| EFFORT=$(echo "$JSON" | jq -r '.effort // empty') | |
| DIFF=$(echo "$JSON" | jq -r '.difficulty // empty') | |
| SUMMARY=$(echo "$JSON" | jq -r '.summary // empty' | tr '\r\n' ' ' | cut -c1-200) | |
| INSIGHT=$(echo "$JSON" | jq -r '.insight // empty' | tr '\r\n' ' ' | cut -c1-1200) | |
| NEEDS=$(echo "$JSON" | jq -r '.needs_info // false') | |
| LABELS=() | |
| case "$TYPE" in | |
| Bug) LABELS+=("T: Bugfix");; | |
| Feature) LABELS+=("T: New Feature");; | |
| # Task has no T: label in the taxonomy - it's conveyed by the GitHub | |
| # issue type set further down, so no label is added here on purpose. | |
| esac | |
| case "$PRIORITY" in | |
| Urgent) LABELS+=("P0: Critical");; | |
| High) LABELS+=("P1: High");; | |
| Medium) LABELS+=("P2: Raised");; | |
| Low) LABELS+=("P3: Standard");; | |
| esac | |
| case "$DIFF" in | |
| DB) LABELS+=("DB: Beginner Friendly");; | |
| D3) LABELS+=("D3: Low");; | |
| D2) LABELS+=("D2: Medium");; | |
| D1) LABELS+=("D1: High");; | |
| D0) LABELS+=("D0: Very High");; | |
| esac | |
| # allowlist the model's area/change labels to the documented taxonomy so a | |
| # stray (but repo-existing) label can't sneak in if the model goes off-schema. | |
| declare -A ALLOWED_AC=( | |
| ["A: Suits"]=1 ["A: Powers"]=1 ["A: Entities"]=1 ["A: Datagen"]=1 | |
| ["A: Admin Tooling"]=1 ["A: Core Tech"]=1 ["A: Build"]=1 ["A: Docs"]=1 | |
| ["C: Render"]=1 ["C: UI"]=1 ["C: Networking"]=1 ["C: Textures"]=1 | |
| ["C: Audio"]=1 ["C: Translations"]=1 ["C: Structures"]=1 ["C: No Java"]=1 | |
| ) | |
| while IFS= read -r l; do | |
| [ -n "$l" ] && [ -n "${ALLOWED_AC[$l]:-}" ] && LABELS+=("$l") | |
| done < <(echo "$JSON" | jq -r '((.areas // []) + (.changes // []))[]') | |
| [ "$NEEDS" = "true" ] && LABELS+=("Issue: Awaiting Response") | |
| # only apply labels that actually exist in the repo - page through all of | |
| # them into a set for O(1) membership instead of a nested scan. | |
| declare -A HAVE | |
| while IFS= read -r e; do [ -n "$e" ] && HAVE["$e"]=1; done \ | |
| < <(gh api "repos/$REPO/labels" --paginate --jq '.[].name') | |
| ADD=() | |
| for l in "${LABELS[@]:-}"; do | |
| [ -n "${HAVE[$l]:-}" ] && ADD+=("--add-label" "$l") | |
| done | |
| if [ "${#ADD[@]}" -gt 0 ]; then | |
| gh issue edit "$NUM" --repo "$REPO" "${ADD[@]}" || true | |
| fi | |
| # GitHub issue type (best effort - org-level) | |
| if [ -n "$TYPE" ]; then | |
| gh api --method PATCH "repos/$REPO/issues/$NUM" -f type="$TYPE" >/dev/null 2>&1 \ | |
| || echo "issue type not set (token may lack org perms)" | |
| fi | |
| # org Priority/Effort issue fields (best effort) | |
| RID=$(gh api "repos/$REPO" --jq '.id') | |
| set_field() { | |
| [ -z "$2" ] && return 0 | |
| # build the payload with jq so the value is properly json-escaped | |
| jq -n --argjson fid "$1" --arg val "$2" \ | |
| '{issue_field_values:[{field_id:$fid,value:$val}]}' \ | |
| | gh api --method POST "repositories/$RID/issues/$NUM/issue-field-values" --input - >/dev/null 2>&1 \ | |
| || echo "field $1 not set" | |
| } | |
| # ids default to this org's Priority/Effort fields; override via the | |
| # PRIORITY_FIELD_ID / EFFORT_FIELD_ID Actions variables if they differ. | |
| set_field "${PRIORITY_FIELD_ID:-30052706}" "$PRIORITY" | |
| set_field "${EFFORT_FIELD_ID:-30052709}" "$EFFORT" | |
| # triage comment | |
| { | |
| echo "🤖 **auto-triage** (github models)" | |
| echo | |
| echo "| type | priority | effort | difficulty |" | |
| echo "|---|---|---|---|" | |
| echo "| ${TYPE:-?} | ${PRIORITY:-?} | ${EFFORT:-?} | ${DIFF:-?} |" | |
| echo | |
| [ -n "$SUMMARY" ] && echo "> $SUMMARY" | |
| echo | |
| if [ -n "$INSIGHT" ]; then | |
| case "$TYPE" in | |
| Bug) echo "### 🔎 Likely cause";; | |
| Feature) echo "### 🛠️ Recommended implementation";; | |
| *) echo "### 🔎 Insight";; | |
| esac | |
| echo | |
| echo "$INSIGHT" | |
| echo | |
| fi | |
| [ "$NEEDS" = "true" ] && echo "looks like it's missing repro steps / logs - added \`Issue: Awaiting Response\`." | |
| echo | |
| echo "_labels and insight are a best guess, a maintainer will confirm._" | |
| } > "$RUNNER_TEMP/comment.md" | |
| gh issue comment "$NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/comment.md" || true |