diff --git a/.github/ISSUE_TEMPLATE/add-new-feature---behavior.md b/.github/ISSUE_TEMPLATE/add-new-feature---behavior.md index 5c3c8c3da1a..ce5cfd9ca31 100644 --- a/.github/ISSUE_TEMPLATE/add-new-feature---behavior.md +++ b/.github/ISSUE_TEMPLATE/add-new-feature---behavior.md @@ -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: '' --- diff --git a/.github/ISSUE_TEMPLATE/report-bug.md b/.github/ISSUE_TEMPLATE/report-bug.md index b9c925cca20..88ac8f7b3c9 100644 --- a/.github/ISSUE_TEMPLATE/report-bug.md +++ b/.github/ISSUE_TEMPLATE/report-bug.md @@ -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: '' --- diff --git a/.github/ISSUE_TEMPLATE/request-technical-work.md b/.github/ISSUE_TEMPLATE/request-technical-work.md index 5c858ab2c20..5052b5ce9f4 100644 --- a/.github/ISSUE_TEMPLATE/request-technical-work.md +++ b/.github/ISSUE_TEMPLATE/request-technical-work.md @@ -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: '' --- diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 2f6c5fd169a..71bc2f7053c 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -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": "", + "issue_type": "", + "is_confirmed_bug": , + "is_support_question": , + "is_feature_suggestion": , "scope": "", "priority": "", "size": "", "good_first_issue": , - "needs_reproduction": , + "needs_reproduction": , + "duplicate_of": , "summary": "" } 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'), }); } diff --git a/scripts/issue-backfill-triage.sh b/scripts/issue-backfill-triage.sh new file mode 100755 index 00000000000..7db348223cc --- /dev/null +++ b/scripts/issue-backfill-triage.sh @@ -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\": \"\", + \"scope\": \"\", + \"priority\": \"\", + \"size\": \"\", + \"is_support_question\": , + \"needs_reproduction\": , + \"good_first_issue\": , + \"duplicate_of\": , + \"summary\": \"\" +}" + + 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 ===" diff --git a/scripts/issue-cleanup-labels.sh b/scripts/issue-cleanup-labels.sh new file mode 100755 index 00000000000..c44bab59f0e --- /dev/null +++ b/scripts/issue-cleanup-labels.sh @@ -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