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:
@@ -2,7 +2,7 @@
|
||||
name: Add new feature / behavior
|
||||
about: Describe the desired product increment
|
||||
title: 'Ex: Add custom field from Companies / People table options menu'
|
||||
labels: ['type: feature']
|
||||
labels: []
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Report a bug
|
||||
about: Report a bug or a functional regression
|
||||
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
|
||||
labels: ['type: bug']
|
||||
labels: []
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Request technical chore
|
||||
about: Request technical work that does not provide any product increment (aka refactoring)
|
||||
title: ''
|
||||
labels: ['type: chore']
|
||||
labels: []
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
@@ -6,16 +6,67 @@ on:
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
# Skip if issue already has labels (e.g. from a template)
|
||||
# or if it mentions @claude (handled by claude.yml)
|
||||
# Skip if issue mentions @claude (handled by claude.yml)
|
||||
if: |
|
||||
github.event.issue.labels[0] == null &&
|
||||
!contains(github.event.issue.title, '@claude') &&
|
||||
!contains(github.event.issue.body, '@claude')
|
||||
!contains(github.event.issue.body || '', '@claude')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Fetch repo context for triage
|
||||
id: context
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// Fetch recent open issues for duplicate detection
|
||||
const issues = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
const issueList = issues.data
|
||||
.filter(i => i.number !== context.payload.issue.number && !i.pull_request)
|
||||
.map(i => `#${i.number}: ${i.title}`)
|
||||
.join('\n');
|
||||
|
||||
core.setOutput('open_issues', issueList);
|
||||
|
||||
// Fetch available issue types via GraphQL
|
||||
try {
|
||||
const result = await github.graphql(`
|
||||
{
|
||||
repository(owner: "${context.repo.owner}", name: "${context.repo.repo}") {
|
||||
issueTypes(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, {
|
||||
headers: { 'GraphQL-Features': 'issue_types' }
|
||||
});
|
||||
const types = result.repository.issueTypes.nodes;
|
||||
core.setOutput('issue_types', JSON.stringify(types));
|
||||
} catch (e) {
|
||||
console.log('Could not fetch issue types:', e.message);
|
||||
core.setOutput('issue_types', '[]');
|
||||
}
|
||||
|
||||
- name: Triage issue with Claude
|
||||
id: triage
|
||||
uses: anthropics/claude-code-action@v1
|
||||
@@ -24,45 +75,56 @@ jobs:
|
||||
direct_prompt: |
|
||||
You are an issue triager for Twenty, an open-source CRM (https://github.com/twentyhq/twenty).
|
||||
|
||||
Analyze the following GitHub issue and classify it. Respond with ONLY a valid JSON object, no other text.
|
||||
Analyze the following GitHub issue and classify it. Respond with ONLY a valid JSON object, no markdown, no explanation.
|
||||
|
||||
Issue #${{ github.event.issue.number }}
|
||||
Title: ${{ github.event.issue.title }}
|
||||
Body:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Available issue types in this repo:
|
||||
${{ steps.context.outputs.issue_types }}
|
||||
|
||||
Recent open issues (check for duplicates):
|
||||
${{ steps.context.outputs.open_issues }}
|
||||
|
||||
Respond with this exact JSON structure:
|
||||
{
|
||||
"type": "<one of: bug, feature, chore, question, design improvement, documentation>",
|
||||
"issue_type": "<one of: Bug, Feature, Task, or null if unclear. Must match an available type name exactly>",
|
||||
"is_confirmed_bug": <true if clearly a bug with reproduction info, false if just a support question or unclear>,
|
||||
"is_support_question": <true if the author is asking for help, troubleshooting, or how to do something>,
|
||||
"is_feature_suggestion": <true if this is a feature request or product suggestion>,
|
||||
"scope": "<one of: front, backend, back+front, infra, self-host, or null if unclear>",
|
||||
"priority": "<one of: critical, high, medium, low>",
|
||||
"size": "<one of: short, medium, long>",
|
||||
"good_first_issue": <true or false>,
|
||||
"needs_reproduction": <true or false - set true for bugs with no clear repro steps>,
|
||||
"needs_reproduction": <true or false - set true for bugs that lack clear steps to reproduce>,
|
||||
"duplicate_of": <issue number (integer) if this appears to be a duplicate, or null>,
|
||||
"summary": "<one sentence summary of what this issue is about>"
|
||||
}
|
||||
|
||||
Classification guidelines:
|
||||
- type "bug": something is broken or not working as expected
|
||||
- type "feature": a new capability or enhancement request
|
||||
- type "chore": refactoring, cleanup, or tech debt with no user-facing change
|
||||
- type "question": user asking how to do something or if something is possible
|
||||
- type "design improvement": UI/UX improvement to existing functionality
|
||||
- type "documentation": docs updates or additions
|
||||
- issue_type "Bug": something is broken or not working as expected (confirmed with evidence)
|
||||
- issue_type "Feature": a new capability, enhancement, or product suggestion
|
||||
- issue_type "Task": refactoring, cleanup, tech debt, documentation, or internal work
|
||||
- is_confirmed_bug: true ONLY if the reporter provides clear evidence (error messages, screenshots, steps to reproduce). If someone says "X doesn't work" with no details, set false and is_support_question to true
|
||||
- is_support_question: true if the issue is really asking "how do I...", "does Twenty support...", "I'm having trouble with...", or troubleshooting a self-hosted setup
|
||||
- is_feature_suggestion: true if proposing new functionality or UX improvements
|
||||
- scope "self-host": issues specific to self-hosted deployments (Docker, env config, migration problems)
|
||||
- scope "front": UI-only issues (React, components, styling)
|
||||
- scope "backend": server-only issues (API, database, GraphQL, workers)
|
||||
- scope "back+front": requires changes in both frontend and backend
|
||||
- scope "infra": CI/CD, deployment, infrastructure
|
||||
- priority "critical": data loss, security vulnerability, or complete feature breakage
|
||||
- priority "critical": data loss, security vulnerability, or complete feature breakage affecting all users
|
||||
- priority "high": significant functionality broken, blocking multiple users
|
||||
- priority "medium": moderate impact, workaround exists
|
||||
- priority "low": minor issue, cosmetic, or nice-to-have
|
||||
- good_first_issue: true only if the fix is straightforward and well-scoped
|
||||
- good_first_issue: true only if the fix is straightforward, well-scoped, and good for newcomers
|
||||
- needs_reproduction: true for bugs that lack clear steps to reproduce
|
||||
- duplicate_of: set ONLY if you are confident the issue is clearly about the same thing as an existing open issue. Do not set for merely related issues.
|
||||
claude_args: '--max-turns 1 --model haiku'
|
||||
|
||||
- name: Apply labels from triage
|
||||
- name: Apply triage results
|
||||
if: steps.triage.outputs.result
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -70,10 +132,9 @@ jobs:
|
||||
let triage;
|
||||
try {
|
||||
const raw = `${{ steps.triage.outputs.result }}`;
|
||||
// Extract JSON from response (Claude may wrap it in markdown)
|
||||
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
console.log('No JSON found in response:', raw);
|
||||
console.log('No JSON found in Claude response:', raw);
|
||||
return;
|
||||
}
|
||||
triage = JSON.parse(jsonMatch[0]);
|
||||
@@ -82,20 +143,53 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = [];
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const validTypes = ['bug', 'feature', 'chore', 'question', 'design improvement', 'documentation'];
|
||||
if (triage.type && validTypes.includes(triage.type)) {
|
||||
labels.push(`type: ${triage.type}`);
|
||||
// 1. Set issue type via GraphQL
|
||||
if (triage.issue_type) {
|
||||
try {
|
||||
const issueTypes = JSON.parse(`${{ steps.context.outputs.issue_types }}`);
|
||||
const matchedType = issueTypes.find(t => t.name === triage.issue_type);
|
||||
if (matchedType) {
|
||||
// Get issue node ID
|
||||
const issueData = await github.graphql(`
|
||||
{
|
||||
repository(owner: "${owner}", name: "${repo}") {
|
||||
issue(number: ${issueNumber}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const issueNodeId = issueData.repository.issue.id;
|
||||
|
||||
await github.graphql(`
|
||||
mutation {
|
||||
updateIssue(input: {
|
||||
id: "${issueNodeId}",
|
||||
issueTypeId: "${matchedType.id}"
|
||||
}) {
|
||||
issue { number }
|
||||
}
|
||||
}
|
||||
`, {
|
||||
headers: { 'GraphQL-Features': 'issue_types' }
|
||||
});
|
||||
console.log(`Set issue type to: ${triage.issue_type}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Could not set issue type: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build label list (no more type: labels — using issue types instead)
|
||||
const labels = [];
|
||||
|
||||
const validScopes = ['front', 'backend', 'back+front', 'infra', 'self-host'];
|
||||
if (triage.scope && validScopes.includes(triage.scope)) {
|
||||
if (triage.scope === 'self-host') {
|
||||
labels.push('type: SELF_HOST');
|
||||
} else {
|
||||
labels.push(`scope: ${triage.scope}`);
|
||||
}
|
||||
labels.push(triage.scope === 'self-host' ? 'scope: self-host' : `scope: ${triage.scope}`);
|
||||
}
|
||||
|
||||
const validPriorities = ['critical', 'high', 'medium', 'low'];
|
||||
@@ -116,50 +210,86 @@ jobs:
|
||||
labels.push('needs: reproduction');
|
||||
}
|
||||
|
||||
if (labels.length === 0) {
|
||||
console.log('No valid labels to apply');
|
||||
return;
|
||||
if (triage.is_support_question === true) {
|
||||
labels.push('support');
|
||||
}
|
||||
|
||||
// Ensure all labels exist before applying
|
||||
const existingLabels = await github.rest.issues.listLabelsForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingLabelNames = existingLabels.data.map(l => l.name);
|
||||
// 3. Ensure labels exist and apply them
|
||||
if (labels.length > 0) {
|
||||
const existingLabels = await github.rest.issues.listLabelsForRepo({
|
||||
owner, repo, per_page: 100,
|
||||
});
|
||||
const existingLabelNames = new Set(existingLabels.data.map(l => l.name));
|
||||
|
||||
for (const label of labels) {
|
||||
if (!existingLabelNames.includes(label)) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: label,
|
||||
color: 'ededed',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not create label "${label}": ${e.message}`);
|
||||
const labelColors = {
|
||||
'needs: reproduction': 'fbca04',
|
||||
'support': '0075ca',
|
||||
'scope: self-host': 'bfd4f2',
|
||||
'size: medium': 'c2e0c6',
|
||||
};
|
||||
|
||||
for (const label of labels) {
|
||||
if (!existingLabelNames.has(label)) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo,
|
||||
name: label,
|
||||
color: labelColors[label] || 'ededed',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Could not create label "${label}": ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo,
|
||||
issue_number: issueNumber,
|
||||
labels,
|
||||
});
|
||||
console.log(`Applied labels: ${labels.join(', ')}`);
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
labels: labels,
|
||||
});
|
||||
// 4. Build triage comment
|
||||
const commentParts = [];
|
||||
|
||||
console.log(`Applied labels: ${labels.join(', ')}`);
|
||||
|
||||
// Post a brief triage comment if summary exists
|
||||
if (triage.summary) {
|
||||
const body = `**Auto-triage:** ${triage.summary}\n\n_Labels applied: ${labels.map(l => '`' + l + '`').join(', ')}_`;
|
||||
commentParts.push(`**Auto-triage:** ${triage.summary}`);
|
||||
}
|
||||
|
||||
// Flag duplicates
|
||||
if (triage.duplicate_of && Number.isInteger(triage.duplicate_of)) {
|
||||
commentParts.push(`\n> **Possible duplicate** of #${triage.duplicate_of} — please check if this is the same issue before proceeding.`);
|
||||
}
|
||||
|
||||
// Guide support questions
|
||||
if (triage.is_support_question === true) {
|
||||
commentParts.push(`\nThis looks like a support question. You may get faster help on our [Discord](https://discord.gg/cx5n7CSns2) or [GitHub Discussions](https://github.com/twentyhq/twenty/discussions).`);
|
||||
commentParts.push(`If this is actually a confirmed bug, please update the issue with steps to reproduce and we'll re-triage it.`);
|
||||
}
|
||||
|
||||
// Guide feature suggestions
|
||||
if (triage.is_feature_suggestion === true && triage.issue_type !== 'Bug') {
|
||||
commentParts.push(`\nThis looks like a feature suggestion. Feature requests are typically discussed in [GitHub Discussions](https://github.com/twentyhq/twenty/discussions) before being converted to tracked issues. A maintainer may move this to discussions.`);
|
||||
}
|
||||
|
||||
// Reproduction needed
|
||||
if (triage.needs_reproduction === true && !triage.is_support_question) {
|
||||
commentParts.push(`\nWe need more information to reproduce this issue. Please provide:\n- Steps to reproduce\n- Expected vs actual behavior\n- Environment details (self-hosted/cloud, version, browser)`);
|
||||
}
|
||||
|
||||
// Applied metadata summary
|
||||
const metaParts = [];
|
||||
if (triage.issue_type) metaParts.push(`Type: **${triage.issue_type}**`);
|
||||
if (labels.length > 0) metaParts.push(`Labels: ${labels.map(l => '`' + l + '`').join(', ')}`);
|
||||
if (metaParts.length > 0) {
|
||||
commentParts.push(`\n${metaParts.join(' · ')}`);
|
||||
}
|
||||
|
||||
if (commentParts.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: body,
|
||||
owner, repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentParts.join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
Executable
+182
@@ -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 ==="
|
||||
Executable
+82
@@ -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
|
||||
Reference in New Issue
Block a user