ha-inlite

Home Assistant integration for in-lite
git clone https://git.stephank.nl/ha-inlite
Log | Files | Refs | README | LICENSE | ZIP

squad-triage.yml (11519B)


      1 name: Squad Triage
      2 
      3 on:
      4   issues:
      5     types: [labeled]
      6 
      7 permissions:
      8   issues: write
      9   contents: read
     10 
     11 jobs:
     12   triage:
     13     if: github.event.label.name == 'squad'
     14     runs-on: ubuntu-latest
     15     steps:
     16       - uses: actions/checkout@v4
     17 
     18       - name: Triage issue via Lead agent
     19         uses: actions/github-script@v7
     20         with:
     21           script: |
     22             const fs = require('fs');
     23             const issue = context.payload.issue;
     24 
     25             // Read team roster — check .squad/ first, fall back to .ai-team/
     26             let teamFile = '.squad/team.md';
     27             if (!fs.existsSync(teamFile)) {
     28               teamFile = '.ai-team/team.md';
     29             }
     30             if (!fs.existsSync(teamFile)) {
     31               core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage');
     32               return;
     33             }
     34 
     35             const content = fs.readFileSync(teamFile, 'utf8');
     36             const lines = content.split('\n');
     37 
     38             // Check if @copilot is on the team
     39             const hasCopilot = content.includes('šŸ¤– Coding Agent');
     40             const copilotAutoAssign = content.includes('<!-- copilot-auto-assign: true -->');
     41 
     42             // Parse @copilot capability profile
     43             let goodFitKeywords = [];
     44             let needsReviewKeywords = [];
     45             let notSuitableKeywords = [];
     46 
     47             if (hasCopilot) {
     48               // Extract capability tiers from team.md
     49               const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i);
     50               const needsReviewMatch = content.match(/🟔\s*Needs review[^:]*:\s*(.+)/i);
     51               const notSuitableMatch = content.match(/šŸ”“\s*Not suitable[^:]*:\s*(.+)/i);
     52 
     53               if (goodFitMatch) {
     54                 goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
     55               } else {
     56                 goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
     57               }
     58               if (needsReviewMatch) {
     59                 needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
     60               } else {
     61                 needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
     62               }
     63               if (notSuitableMatch) {
     64                 notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
     65               } else {
     66                 notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
     67               }
     68             }
     69 
     70             const members = [];
     71             let inMembersTable = false;
     72             for (const line of lines) {
     73               if (line.match(/^##\s+(Members|Team Roster)/i)) {
     74                 inMembersTable = true;
     75                 continue;
     76               }
     77               if (inMembersTable && line.startsWith('## ')) {
     78                 break;
     79               }
     80               if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
     81                 const cells = line.split('|').map(c => c.trim()).filter(Boolean);
     82                 if (cells.length >= 2 && cells[0] !== 'Scribe') {
     83                   members.push({
     84                     name: cells[0],
     85                     role: cells[1]
     86                   });
     87                 }
     88               }
     89             }
     90 
     91             // Read routing rules — check .squad/ first, fall back to .ai-team/
     92             let routingFile = '.squad/routing.md';
     93             if (!fs.existsSync(routingFile)) {
     94               routingFile = '.ai-team/routing.md';
     95             }
     96             let routingContent = '';
     97             if (fs.existsSync(routingFile)) {
     98               routingContent = fs.readFileSync(routingFile, 'utf8');
     99             }
    100 
    101             // Find the Lead
    102             const lead = members.find(m =>
    103               m.role.toLowerCase().includes('lead') ||
    104               m.role.toLowerCase().includes('architect') ||
    105               m.role.toLowerCase().includes('coordinator')
    106             );
    107 
    108             if (!lead) {
    109               core.warning('No Lead role found in team roster — cannot triage');
    110               return;
    111             }
    112 
    113             // Build triage context
    114             const memberList = members.map(m =>
    115               `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
    116             ).join('\n');
    117 
    118             // Determine best assignee based on issue content and routing
    119             const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
    120 
    121             let assignedMember = null;
    122             let triageReason = '';
    123             let copilotTier = null;
    124 
    125             // First, evaluate @copilot fit if enabled
    126             if (hasCopilot) {
    127               const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
    128               const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
    129               const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
    130 
    131               if (isGoodFit) {
    132                 copilotTier = 'good-fit';
    133                 assignedMember = { name: '@copilot', role: 'Coding Agent' };
    134                 triageReason = '🟢 Good fit for @copilot — matches capability profile';
    135               } else if (isNeedsReview) {
    136                 copilotTier = 'needs-review';
    137                 assignedMember = { name: '@copilot', role: 'Coding Agent' };
    138                 triageReason = '🟔 Routing to @copilot (needs review) — a squad member should review the PR';
    139               } else if (isNotSuitable) {
    140                 copilotTier = 'not-suitable';
    141                 // Fall through to normal routing
    142               }
    143             }
    144 
    145             // If not routed to @copilot, use keyword-based routing
    146             if (!assignedMember) {
    147               for (const member of members) {
    148                 const role = member.role.toLowerCase();
    149                 if ((role.includes('frontend') || role.includes('ui')) &&
    150                     (issueText.includes('ui') || issueText.includes('frontend') ||
    151                      issueText.includes('css') || issueText.includes('component') ||
    152                      issueText.includes('button') || issueText.includes('page') ||
    153                      issueText.includes('layout') || issueText.includes('design'))) {
    154                   assignedMember = member;
    155                   triageReason = 'Issue relates to frontend/UI work';
    156                   break;
    157                 }
    158                 if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
    159                     (issueText.includes('api') || issueText.includes('backend') ||
    160                      issueText.includes('database') || issueText.includes('endpoint') ||
    161                      issueText.includes('server') || issueText.includes('auth'))) {
    162                   assignedMember = member;
    163                   triageReason = 'Issue relates to backend/API work';
    164                   break;
    165                 }
    166                 if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
    167                     (issueText.includes('test') || issueText.includes('bug') ||
    168                      issueText.includes('fix') || issueText.includes('regression') ||
    169                      issueText.includes('coverage'))) {
    170                   assignedMember = member;
    171                   triageReason = 'Issue relates to testing/quality work';
    172                   break;
    173                 }
    174                 if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
    175                     (issueText.includes('deploy') || issueText.includes('ci') ||
    176                      issueText.includes('pipeline') || issueText.includes('docker') ||
    177                      issueText.includes('infrastructure'))) {
    178                   assignedMember = member;
    179                   triageReason = 'Issue relates to DevOps/infrastructure work';
    180                   break;
    181                 }
    182               }
    183             }
    184 
    185             // Default to Lead if no routing match
    186             if (!assignedMember) {
    187               assignedMember = lead;
    188               triageReason = 'No specific domain match — assigned to Lead for further analysis';
    189             }
    190 
    191             const isCopilot = assignedMember.name === '@copilot';
    192             const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
    193 
    194             // Add the member-specific label
    195             await github.rest.issues.addLabels({
    196               owner: context.repo.owner,
    197               repo: context.repo.repo,
    198               issue_number: issue.number,
    199               labels: [assignLabel]
    200             });
    201 
    202             // Apply default triage verdict
    203             await github.rest.issues.addLabels({
    204               owner: context.repo.owner,
    205               repo: context.repo.repo,
    206               issue_number: issue.number,
    207               labels: ['go:needs-research']
    208             });
    209 
    210             // Auto-assign @copilot if enabled
    211             if (isCopilot && copilotAutoAssign) {
    212               try {
    213                 await github.rest.issues.addAssignees({
    214                   owner: context.repo.owner,
    215                   repo: context.repo.repo,
    216                   issue_number: issue.number,
    217                   assignees: ['copilot']
    218                 });
    219               } catch (err) {
    220                 core.warning(`Could not auto-assign @copilot: ${err.message}`);
    221               }
    222             }
    223 
    224             // Build copilot evaluation note
    225             let copilotNote = '';
    226             if (hasCopilot && !isCopilot) {
    227               if (copilotTier === 'not-suitable') {
    228                 copilotNote = `\n\n**@copilot evaluation:** šŸ”“ Not suitable — issue involves work outside the coding agent's capability profile.`;
    229               } else {
    230                 copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
    231               }
    232             }
    233 
    234             // Post triage comment
    235             const comment = [
    236               `### šŸ—ļø Squad Triage — ${lead.name} (${lead.role})`,
    237               '',
    238               `**Issue:** #${issue.number} — ${issue.title}`,
    239               `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
    240               `**Reason:** ${triageReason}`,
    241               copilotTier === 'needs-review' ? `\nāš ļø **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
    242               copilotNote,
    243               '',
    244               `---`,
    245               '',
    246               `**Team roster:**`,
    247               memberList,
    248               hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
    249               '',
    250               `> To reassign, remove the current \`squad:*\` label and add the correct one.`,
    251             ].filter(Boolean).join('\n');
    252 
    253             await github.rest.issues.createComment({
    254               owner: context.repo.owner,
    255               repo: context.repo.repo,
    256               issue_number: issue.number,
    257               body: comment
    258             });
    259 
    260             core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);