feat: comprehensive issue management with auto-triage, cleanup, and stale bot

Auto-triage workflow (issue-triage.yml):
- Classifies new issues using Claude Haiku (single turn, low cost)
- Sets GitHub issue types (Bug/Feature/Task) via GraphQL API
- Applies scope, priority, and size labels automatically
- Detects potential duplicates by comparing against open issues
- Guides support questions to Discord/Discussions
- Flags feature suggestions for discussion conversion
- Requests reproduction steps for unconfirmed bugs

Stale issue workflow (issue-stale.yml):
- Marks issues stale after 90 days of inactivity
- Auto-closes after 14 more days
- Exempts critical, high priority, and bounty issues

Issue template updates:
- Remove all "type: *" labels from templates (replaced by issue types)
- Auto-triage handles classification for all templates uniformly

One-time scripts (scripts/):
- issue-cleanup-labels.sh: deletes obsolete type labels and typos
- issue-backfill-triage.sh: triages existing 154 unlabeled issues

https://claude.ai/code/session_012t1J7hF5Th74qF9Fcn8h3h
This commit is contained in:
Claude
2026-03-18 09:24:18 +00:00
parent 6414d6d41d
commit ea30dbcd99
6 changed files with 459 additions and 65 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# One-time script to triage existing unlabeled issues using Claude.
# Run locally with authenticated `gh` and `claude` (Claude Code CLI):
#
# bash scripts/issue-backfill-triage.sh [--dry-run] [--limit N]
#
# Prerequisites:
# - gh CLI authenticated with repo write access
# - Claude Code CLI installed (npm install -g @anthropic-ai/claude-code)
# - ANTHROPIC_API_KEY set in environment
#
# What it does:
# 1. Fetches all open issues with no labels
# 2. For each, calls Claude to classify it
# 3. Sets the issue type via GraphQL
# 4. Applies scope/priority/size labels
# 5. Leaves a triage comment
set -euo pipefail
REPO="twentyhq/twenty"
DRY_RUN=false
LIMIT=0
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run) DRY_RUN=true; shift ;;
--limit) LIMIT=$2; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
echo "=== Backfill Triage for $REPO ==="
echo "Dry run: $DRY_RUN"
[ "$LIMIT" -gt 0 ] && echo "Limit: $LIMIT issues"
# Fetch issue types
echo "Fetching issue types..."
ISSUE_TYPES=$(gh api graphql \
-H "GraphQL-Features: issue_types" \
-f query='{
repository(owner: "twentyhq", name: "twenty") {
issueTypes(first: 20) {
nodes { id name }
}
}
}' --jq '.data.repository.issueTypes.nodes')
echo "Available types: $(echo "$ISSUE_TYPES" | jq -r '.[].name' | tr '\n' ', ')"
# Fetch all open issues for duplicate-check context
echo "Fetching open issues for context..."
ALL_ISSUES=$(gh issue list -R "$REPO" --state open --limit 200 --json number,title --jq '.[] | "#\(.number): \(.title)"')
# Fetch unlabeled issues
echo "Fetching unlabeled open issues..."
UNLABELED=$(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
--jq '.[] | select(.labels | length == 0) | select(.pull_request == null) | "\(.number)\t\(.title)"')
COUNT=$(echo "$UNLABELED" | grep -c . || true)
echo "Found $COUNT unlabeled issues"
[ "$COUNT" -eq 0 ] && { echo "Nothing to do!"; exit 0; }
PROCESSED=0
while IFS=$'\t' read -r number title; do
[ -z "$number" ] && continue
PROCESSED=$((PROCESSED + 1))
[ "$LIMIT" -gt 0 ] && [ "$PROCESSED" -gt "$LIMIT" ] && break
echo ""
echo "--- Processing #$number: $title ($PROCESSED/$COUNT) ---"
# Fetch full issue body
BODY=$(gh issue view "$number" -R "$REPO" --json body --jq '.body' 2>/dev/null || echo "")
# Call Claude for classification
PROMPT="You are an issue triager for Twenty CRM. Classify this issue. Respond with ONLY valid JSON.
Issue #$number
Title: $title
Body:
$BODY
Recent open issues (for duplicate check):
$ALL_ISSUES
{
\"issue_type\": \"<Bug|Feature|Task|null>\",
\"scope\": \"<front|backend|back+front|infra|self-host|null>\",
\"priority\": \"<critical|high|medium|low>\",
\"size\": \"<short|medium|long>\",
\"is_support_question\": <true|false>,
\"needs_reproduction\": <true|false>,
\"good_first_issue\": <true|false>,
\"duplicate_of\": <number|null>,
\"summary\": \"<one sentence>\"
}"
RESULT=$(echo "$PROMPT" | claude --model haiku --print 2>/dev/null || echo "")
if [ -z "$RESULT" ]; then
echo " Claude returned empty, skipping"
continue
fi
# Extract JSON
JSON=$(echo "$RESULT" | python3 -c "
import sys, json, re
text = sys.stdin.read()
match = re.search(r'\{[\s\S]*\}', text)
if match:
obj = json.loads(match.group())
print(json.dumps(obj))
else:
sys.exit(1)
" 2>/dev/null || echo "")
if [ -z "$JSON" ]; then
echo " Could not parse JSON from Claude response, skipping"
continue
fi
echo " Classification: $(echo "$JSON" | jq -c '{type: .issue_type, scope: .scope, prio: .priority}')"
if [ "$DRY_RUN" = true ]; then
echo " [DRY RUN] Would apply: $JSON"
continue
fi
# Set issue type
ISSUE_TYPE=$(echo "$JSON" | jq -r '.issue_type // empty')
if [ -n "$ISSUE_TYPE" ] && [ "$ISSUE_TYPE" != "null" ]; then
TYPE_ID=$(echo "$ISSUE_TYPES" | jq -r --arg name "$ISSUE_TYPE" '.[] | select(.name == $name) | .id')
if [ -n "$TYPE_ID" ]; then
ISSUE_NODE_ID=$(gh api graphql -f query="{
repository(owner: \"twentyhq\", name: \"twenty\") {
issue(number: $number) { id }
}
}" --jq '.data.repository.issue.id')
gh api graphql \
-H "GraphQL-Features: issue_types" \
-f query="mutation {
updateIssue(input: {id: \"$ISSUE_NODE_ID\", issueTypeId: \"$TYPE_ID\"}) {
issue { number }
}
}" >/dev/null 2>&1 && echo " Set type: $ISSUE_TYPE" || echo " Failed to set type"
fi
fi
# Build and apply labels
LABELS=()
SCOPE=$(echo "$JSON" | jq -r '.scope // empty')
PRIO=$(echo "$JSON" | jq -r '.priority // empty')
SIZE=$(echo "$JSON" | jq -r '.size // empty')
IS_SUPPORT=$(echo "$JSON" | jq -r '.is_support_question // false')
NEEDS_REPRO=$(echo "$JSON" | jq -r '.needs_reproduction // false')
GOOD_FIRST=$(echo "$JSON" | jq -r '.good_first_issue // false')
[ -n "$SCOPE" ] && [ "$SCOPE" != "null" ] && LABELS+=("scope: $SCOPE")
[ -n "$PRIO" ] && [ "$PRIO" != "null" ] && LABELS+=("prio: $PRIO")
[ -n "$SIZE" ] && [ "$SIZE" != "null" ] && LABELS+=("size: $SIZE")
[ "$IS_SUPPORT" = "true" ] && LABELS+=("support")
[ "$NEEDS_REPRO" = "true" ] && LABELS+=("needs: reproduction")
[ "$GOOD_FIRST" = "true" ] && LABELS+=("good first issue")
if [ ${#LABELS[@]} -gt 0 ]; then
LABEL_ARGS=""
for l in "${LABELS[@]}"; do
LABEL_ARGS="$LABEL_ARGS --add-label \"$l\""
done
eval "gh issue edit $number -R $REPO $LABEL_ARGS" 2>/dev/null && \
echo " Applied labels: ${LABELS[*]}" || echo " Failed to apply labels"
fi
# Brief pause to avoid rate limits
sleep 1
done <<< "$UNLABELED"
echo ""
echo "=== Backfill complete! Processed $PROCESSED issues ==="
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# One-time script to clean up issue labels.
# Run locally with an authenticated `gh` CLI:
# bash scripts/issue-cleanup-labels.sh
#
# What it does:
# 1. Deletes typo / stale / obsolete labels
# 2. Renames labels that are miscategorized
# 3. Removes all "type: *" labels (replaced by GitHub issue types)
#
# Safe to run multiple times (idempotent).
set -euo pipefail
REPO="twentyhq/twenty"
echo "=== Issue Label Cleanup for $REPO ==="
# Labels to delete outright (typos, stale, or obsolete)
DELETE_LABELS=(
"hihg"
"for exa"
"HACKTOBERFEST_2025"
"type: bug"
"type: chore"
"type: feature"
"type: design improvement"
"type: documentation"
"type: marketing"
"type: SELF_HOST"
)
for label in "${DELETE_LABELS[@]}"; do
echo "Deleting label: '$label'"
gh label delete "$label" -R "$REPO" --yes 2>/dev/null || echo " (not found or already deleted)"
done
# Create new labels that the triage workflow needs
echo ""
echo "=== Creating new labels ==="
declare -A NEW_LABELS=(
["scope: self-host"]="bfd4f2:Issues specific to self-hosted deployments"
["needs: reproduction"]="fbca04:Bug reports that lack steps to reproduce"
["support"]="0075ca:Support questions / troubleshooting"
["size: medium"]="c2e0c6:Medium-sized task"
)
for label in "${!NEW_LABELS[@]}"; do
IFS=':' read -r color desc <<< "${NEW_LABELS[$label]}"
echo "Creating label: '$label' ($desc)"
gh label create "$label" -R "$REPO" --color "$color" --description "$desc" 2>/dev/null || echo " (already exists)"
done
echo ""
echo "=== Removing 'type: *' labels from existing issues ==="
# For each type label, find issues that have it and remove it
TYPE_LABELS=(
"type: bug"
"type: chore"
"type: feature"
"type: design improvement"
"type: documentation"
"type: marketing"
"type: SELF_HOST"
)
for label in "${TYPE_LABELS[@]}"; do
issues=$(gh issue list -R "$REPO" --state all --label "$label" --json number --jq '.[].number' 2>/dev/null || true)
if [ -n "$issues" ]; then
echo "Removing '$label' from issues: $issues"
for num in $issues; do
gh issue edit "$num" -R "$REPO" --remove-label "$label" 2>/dev/null || true
done
fi
done
echo ""
echo "=== Done! ==="
echo "Remaining labels:"
gh label list -R "$REPO" --limit 100