Define a general catalog-compatibility contract for external --data catalogs (ADR-015 DD4) #1950
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
| # Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| name: Issue Triage | |
| on: | |
| issues: | |
| types: [opened, labeled] | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: read | |
| jobs: | |
| triage: | |
| name: Auto-Triage Issues | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| issues: write | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Add needs-triage on new issue | |
| if: github.event.action == 'opened' | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| labels: ['needs-triage'] | |
| }); | |
| - name: Infer area label on new issue | |
| if: github.event.action == 'opened' | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const title = context.payload.issue.title || ''; | |
| const body = context.payload.issue.body || ''; | |
| const existingLabels = new Set((context.payload.issue.labels || []).map(label => label.name)); | |
| const componentPatterns = [ | |
| { pattern: /\bcli\b/, label: 'area/cli' }, | |
| { pattern: /\bapi server\b|\baicrd\b/, label: 'area/api' }, | |
| { pattern: /\brecipe engine\b|\brecipe engine \/ data\b/, label: 'area/recipes' }, | |
| { pattern: /\bbundler\b|\bbundlers\b/, label: 'area/bundler' }, | |
| { pattern: /\bcollectors?\b|\bsnapshotter\b/, label: 'area/collector' }, | |
| { pattern: /\bvalidator\b/, label: 'area/validator' }, | |
| { pattern: /\bcore libraries\b/, label: 'area/infra' }, | |
| { pattern: /\bdocs\b|\bdocumentation\b/, label: 'area/docs' }, | |
| ]; | |
| const tokenMap = new Map([ | |
| ['api', 'area/api'], | |
| ['aicrd', 'area/api'], | |
| ['server', 'area/api'], | |
| ['bundle', 'area/bundler'], | |
| ['bundler', 'area/bundler'], | |
| ['bundlers', 'area/bundler'], | |
| ['argocd', 'area/bundler'], | |
| ['chart', 'area/bundler'], | |
| ['charts', 'area/bundler'], | |
| ['helm', 'area/bundler'], | |
| ['ci', 'area/ci'], | |
| ['workflow', 'area/ci'], | |
| ['workflows', 'area/ci'], | |
| ['action', 'area/ci'], | |
| ['actions', 'area/ci'], | |
| ['cli', 'area/cli'], | |
| ['command', 'area/cli'], | |
| ['collector', 'area/collector'], | |
| ['collectors', 'area/collector'], | |
| ['snapshot', 'area/collector'], | |
| ['snapshotter', 'area/collector'], | |
| ['topology', 'area/collector'], | |
| ['docs', 'area/docs'], | |
| ['documentation', 'area/docs'], | |
| ['readme', 'area/docs'], | |
| ['infra', 'area/infra'], | |
| ['infrastructure', 'area/infra'], | |
| ['recipe', 'area/recipes'], | |
| ['recipes', 'area/recipes'], | |
| ['overlay', 'area/recipes'], | |
| ['overlays', 'area/recipes'], | |
| ['mixin', 'area/recipes'], | |
| ['mixins', 'area/recipes'], | |
| ['test', 'area/tests'], | |
| ['tests', 'area/tests'], | |
| ['chainsaw', 'area/tests'], | |
| ['e2e', 'area/tests'], | |
| ['kwok', 'area/tests'], | |
| ['validate', 'area/validator'], | |
| ['validation', 'area/validator'], | |
| ['validator', 'area/validator'], | |
| ['validators', 'area/validator'], | |
| ['conformance', 'area/validator'], | |
| ['deployment', 'area/validator'], | |
| ['performance', 'area/validator'], | |
| ]); | |
| function normalize(value) { | |
| return value.trim().toLowerCase(); | |
| } | |
| // Sentinel values from issue templates that indicate ambiguous/unknown | |
| // components. When these are selected, ignore the component field | |
| // and continue inferring from title/body text. | |
| const AMBIGUOUS_COMPONENTS = [ | |
| 'multiple components', | |
| 'new component', | |
| 'other / unknown', | |
| 'other', | |
| 'unknown', | |
| ]; | |
| function inferFromComponent() { | |
| const match = body.match(/^#{2,3}\s+Component\s*$[\r\n]+(?:[\r\n]*)([^\r\n]+)/im); | |
| if (!match) { | |
| return null; | |
| } | |
| const component = normalize(match[1]); | |
| if (AMBIGUOUS_COMPONENTS.includes(component)) { | |
| core.info(`Component "${match[1].trim()}" is ambiguous — falling back to title/body inference`); | |
| return null; | |
| } | |
| for (const { pattern, label } of componentPatterns) { | |
| if (pattern.test(component)) { | |
| return label; | |
| } | |
| } | |
| return null; | |
| } | |
| function inferFromScope() { | |
| const scopeMatch = title.toLowerCase().match(/^[a-z]+\(([^)]+)\):/); | |
| if (!scopeMatch) { | |
| return null; | |
| } | |
| for (const token of scopeMatch[1].split(/[^a-z0-9]+/)) { | |
| const label = tokenMap.get(token); | |
| if (label) { | |
| return label; | |
| } | |
| } | |
| return null; | |
| } | |
| function inferFromText(text) { | |
| const normalized = text.toLowerCase(); | |
| const orderedPatterns = [ | |
| { pattern: /\b(pkg\/api|api\/|aicrd|api server)\b/, label: 'area/api' }, | |
| { pattern: /\b(pkg\/bundler|aicr bundle|bundle output|argocd|helm chart|oci bundle)\b/, label: 'area/bundler' }, | |
| { pattern: /\b(pkg\/cli|command line|cli)\b/, label: 'area/cli' }, | |
| { pattern: /\b(pkg\/collector|snapshotter|snapshot|collector|collectors|topology)\b/, label: 'area/collector' }, | |
| { pattern: /\b(docs\/|documentation|readme|docs)\b/, label: 'area/docs' }, | |
| { pattern: /\b(infra\/|infrastructure)\b/, label: 'area/infra' }, | |
| { pattern: /\b(tests\/|chainsaw|kwok|e2e|test trigger|gpu test)\b/, label: 'area/tests' }, | |
| { pattern: /(?:\.github\/|\bgithub actions\b|\bworkflow\b|\bworkflows\b|\bci\b)/, label: 'area/ci' }, | |
| { | |
| pattern: /\b(pkg\/validator|validators?\/|validators?|validation|validate command|aicr validate|conformance|deployment(?:-phase checks| validator)|performance check)\b/, | |
| label: 'area/validator' | |
| }, | |
| { pattern: /\b(recipes\/|recipe engine|recipemetadata|reciperesult|registry\.yaml|overlay|overlays|mixin|mixins|recipe)\b/, label: 'area/recipes' }, | |
| ]; | |
| for (const { pattern, label } of orderedPatterns) { | |
| if (pattern.test(normalized)) { | |
| return label; | |
| } | |
| } | |
| return null; | |
| } | |
| let areaLabel = [...existingLabels].find(label => label.startsWith('area/')) || null; | |
| if (!areaLabel) { | |
| areaLabel = inferFromComponent(); | |
| } | |
| if (!areaLabel && existingLabels.has('documentation')) { | |
| areaLabel = 'area/docs'; | |
| } | |
| if (!areaLabel) { | |
| areaLabel = inferFromScope(); | |
| } | |
| if (!areaLabel) { | |
| areaLabel = inferFromText(title); | |
| } | |
| if (!areaLabel) { | |
| areaLabel = inferFromText(body); | |
| } | |
| if (!areaLabel) { | |
| core.info('No area label inferred for this issue'); | |
| return; | |
| } | |
| if (!existingLabels.has(areaLabel)) { | |
| core.info(`Adding inferred area label: ${areaLabel}`); | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| labels: [areaLabel], | |
| }); | |
| } else { | |
| core.info(`Issue already has ${areaLabel}`); | |
| } | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| name: 'needs-triage', | |
| }); | |
| core.info('Removed needs-triage after area assignment'); | |
| } catch (error) { | |
| if (error.status === 404) { | |
| core.info('needs-triage label not present'); | |
| return; | |
| } | |
| throw error; | |
| } | |
| - name: Remove needs-triage when triaged | |
| if: >- | |
| github.event.action == 'labeled' && | |
| github.event.label.name != 'needs-triage' | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | |
| with: | |
| script: | | |
| const { data: labels } = await github.rest.issues.listLabelsOnIssue({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| if (labels.some(l => l.name === 'needs-triage')) { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| name: 'needs-triage', | |
| }); | |
| } |