1+ name : Label issues based on keywords
2+ on :
3+ issues :
4+ types : [opened, edited, reopened]
5+ permissions :
6+ issues : write # needed so the workflow can add labels
7+ contents : read
8+ concurrency :
9+ group : issue-labeler-${{ github.event.issue.number }}
10+ cancel-in-progress : true
11+ jobs :
12+ add-labels :
13+ runs-on : ubuntu-latest
14+ steps :
15+ - name : Label issues based on keywords
16+ uses : actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
17+ with :
18+ script : |
19+ // Configuration: Add new labels and keywords here
20+ const labelConfig = {
21+ rocm: {
22+ // Keyword search - matches whole words only (with word boundaries)
23+ keywords: [
24+ {
25+ term: "composable kernel",
26+ searchIn: "both"
27+ },
28+ {
29+ term: "rccl",
30+ searchIn: "body" // only search in body
31+ },
32+ {
33+ term: "migraphx",
34+ searchIn: "title" // only search in title
35+ },
36+ {
37+ term: "hipgraph",
38+ searchIn: "both"
39+ },
40+ {
41+ term: "ROCm System Management Interface",
42+ searchIn: "body"
43+ },
44+ ],
45+
46+ // Substring search - matches anywhere in text (partial matches)
47+ substrings: [
48+ {
49+ term: "VLLM_ROCM_",
50+ searchIn: "both"
51+ },
52+ {
53+ term: "rocm",
54+ searchIn: "title"
55+ },
56+ {
57+ term: "amd",
58+ searchIn: "title"
59+ },
60+ {
61+ term: "hip-",
62+ searchIn: "both"
63+ },
64+ {
65+ term: "gfx",
66+ searchIn: "both"
67+ },
68+ {
69+ term: "cdna",
70+ searchIn: "both"
71+ },
72+ {
73+ term: "rdna",
74+ searchIn: "both"
75+ },
76+ {
77+ term: "torch_hip",
78+ searchIn: "body" // only in body
79+ },
80+ {
81+ term: "_hip",
82+ searchIn: "both"
83+ },
84+ {
85+ term: "hip_",
86+ searchIn: "both"
87+ },
88+
89+ // ROCm tools and libraries
90+ {
91+ term: "hipify",
92+ searchIn: "both"
93+ },
94+ ],
95+
96+ // Regex patterns - for complex pattern matching
97+ regexPatterns: [
98+ {
99+ pattern: "\\bmi\\d{3}[a-z]*\\b",
100+ description: "AMD GPU names (mi + 3 digits + optional letters)",
101+ flags: "gi",
102+ searchIn: "both" // "title", "body", or "both"
103+ }
104+ ],
105+ },
106+ };
107+
108+ // Helper function to create regex based on search type
109+ function createSearchRegex(term, type) {
110+ // Escape special regex characters in the term
111+ const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112+
113+ switch (type) {
114+ case 'keyword':
115+ // Word boundary search - matches whole words only
116+ return new RegExp(`\\b${escapedTerm}\\b`, "gi");
117+ case 'substring':
118+ // Substring search - matches anywhere in the text
119+ return new RegExp(escapedTerm, "gi");
120+ default:
121+ throw new Error(`Unknown search type: ${type}`);
122+ }
123+ }
124+
125+ // Helper function to find matching terms in text with line information
126+ function findMatchingTermsWithLines(text, searchTerms = [], searchType = 'keyword', searchLocation = '') {
127+ const matches = [];
128+ const lines = text.split('\n');
129+
130+ for (const termConfig of searchTerms) {
131+ let regex;
132+ let term, searchIn, pattern, description, flags;
133+
134+ // Handle different input formats (string or object)
135+ if (typeof termConfig === 'string') {
136+ term = termConfig;
137+ searchIn = 'both'; // default
138+ } else {
139+ term = termConfig.term;
140+ searchIn = termConfig.searchIn || 'both';
141+ pattern = termConfig.pattern;
142+ description = termConfig.description;
143+ flags = termConfig.flags;
144+ }
145+
146+ // Skip if this term shouldn't be searched in the current location
147+ if (searchIn !== 'both' && searchIn !== searchLocation) {
148+ continue;
149+ }
150+
151+ // Create appropriate regex
152+ if (searchType === 'regex') {
153+ regex = new RegExp(pattern, flags || "gi");
154+ } else {
155+ regex = createSearchRegex(term, searchType);
156+ }
157+
158+ const termMatches = [];
159+
160+ // Check each line for matches
161+ lines.forEach((line, lineIndex) => {
162+ const lineMatches = line.match(regex);
163+ if (lineMatches) {
164+ lineMatches.forEach(match => {
165+ termMatches.push({
166+ match: match,
167+ lineNumber: lineIndex + 1,
168+ lineContent: line.trim(),
169+ searchType: searchType,
170+ searchLocation: searchLocation,
171+ originalTerm: term || pattern,
172+ description: description,
173+ // Show context around the match in the line
174+ context: line.length > 100 ?
175+ line.substring(Math.max(0, line.toLowerCase().indexOf(match.toLowerCase()) - 30),
176+ line.toLowerCase().indexOf(match.toLowerCase()) + match.length + 30) + '...'
177+ : line.trim()
178+ });
179+ });
180+ }
181+ });
182+
183+ if (termMatches.length > 0) {
184+ matches.push({
185+ term: term || (description || pattern),
186+ searchType: searchType,
187+ searchLocation: searchLocation,
188+ searchIn: searchIn,
189+ pattern: pattern,
190+ matches: termMatches,
191+ count: termMatches.length
192+ });
193+ }
194+ }
195+
196+ return matches;
197+ }
198+
199+ // Helper function to check if label should be added
200+ async function processLabel(labelName, config) {
201+ const body = context.payload.issue.body || "";
202+ const title = context.payload.issue.title || "";
203+
204+ core.notice(`Processing label: ${labelName}`);
205+ core.notice(`Issue Title: "${title}"`);
206+ core.notice(`Issue Body length: ${body.length} characters`);
207+
208+ let shouldAddLabel = false;
209+ let allMatches = [];
210+ let reason = '';
211+
212+ const keywords = config.keywords || [];
213+ const substrings = config.substrings || [];
214+ const regexPatterns = config.regexPatterns || [];
215+
216+ core.notice(`Searching with ${keywords.length} keywords, ${substrings.length} substrings, and ${regexPatterns.length} regex patterns`);
217+
218+ // Search in title
219+ if (title.trim()) {
220+ core.notice(`Searching in title: "${title}"`);
221+
222+ const titleKeywordMatches = findMatchingTermsWithLines(title, keywords, 'keyword', 'title');
223+ const titleSubstringMatches = findMatchingTermsWithLines(title, substrings, 'substring', 'title');
224+ const titleRegexMatches = findMatchingTermsWithLines(title, regexPatterns, 'regex', 'title');
225+
226+ allMatches.push(...titleKeywordMatches, ...titleSubstringMatches, ...titleRegexMatches);
227+ }
228+
229+ // Search in body
230+ if (body.trim()) {
231+ core.notice(`Searching in body (${body.length} characters)`);
232+
233+ const bodyKeywordMatches = findMatchingTermsWithLines(body, keywords, 'keyword', 'body');
234+ const bodySubstringMatches = findMatchingTermsWithLines(body, substrings, 'substring', 'body');
235+ const bodyRegexMatches = findMatchingTermsWithLines(body, regexPatterns, 'regex', 'body');
236+
237+ allMatches.push(...bodyKeywordMatches, ...bodySubstringMatches, ...bodyRegexMatches);
238+ }
239+
240+ if (allMatches.length > 0) {
241+ core.notice(`Found ${allMatches.length} matching term(s):`);
242+
243+ for (const termMatch of allMatches) {
244+ const locationText = termMatch.searchLocation === 'title' ? 'title' : 'body';
245+ const searchInText = termMatch.searchIn === 'both' ? 'both' : termMatch.searchIn;
246+
247+ if (termMatch.searchType === 'regex') {
248+ core.notice(` 📍 Regex: "${termMatch.term}" (pattern: ${termMatch.pattern}) found ${termMatch.count} time(s) in ${locationText} (configured to search in: ${searchInText}):`);
249+ } else {
250+ core.notice(` 📍 Term: "${termMatch.term}" (${termMatch.searchType} search) found ${termMatch.count} time(s) in ${locationText} (configured to search in: ${searchInText}):`);
251+ }
252+
253+ // Show details for each match
254+ termMatch.matches.forEach((match, index) => {
255+ core.notice(` ${index + 1}. Line ${match.lineNumber} in ${match.searchLocation}: "${match.match}" [${match.searchType}]`);
256+ if (match.description) {
257+ core.notice(` Description: ${match.description}`);
258+ }
259+ core.notice(` Context: ${match.context}`);
260+ if (match.lineContent !== match.context) {
261+ core.notice(` Full line: ${match.lineContent}`);
262+ }
263+ });
264+ }
265+
266+ shouldAddLabel = true;
267+ const totalMatches = allMatches.reduce((sum, t) => sum + t.count, 0);
268+ const titleMatches = allMatches.filter(t => t.searchLocation === 'title').reduce((sum, t) => sum + t.count, 0);
269+ const bodyMatches = allMatches.filter(t => t.searchLocation === 'body').reduce((sum, t) => sum + t.count, 0);
270+ const keywordMatches = allMatches.filter(t => t.searchType === 'keyword').reduce((sum, t) => sum + t.count, 0);
271+ const substringMatches = allMatches.filter(t => t.searchType === 'substring').reduce((sum, t) => sum + t.count, 0);
272+ const regexMatches = allMatches.filter(t => t.searchType === 'regex').reduce((sum, t) => sum + t.count, 0);
273+
274+ reason = `Found ${totalMatches} total matches (${titleMatches} in title, ${bodyMatches} in body) - ${keywordMatches} keyword matches, ${substringMatches} substring matches, ${regexMatches} regex matches`;
275+ }
276+
277+ core.notice(`Final decision: ${shouldAddLabel ? 'ADD LABEL' : 'DO NOT ADD LABEL'}`);
278+ core.notice(`Reason: ${reason || 'No matching terms found'}`);
279+
280+ if (shouldAddLabel) {
281+ const existingLabels = context.payload.issue.labels.map(l => l.name);
282+ if (!existingLabels.includes(labelName)) {
283+ await github.rest.issues.addLabels({
284+ owner: context.repo.owner,
285+ repo: context.repo.repo,
286+ issue_number: context.issue.number,
287+ labels: [labelName],
288+ });
289+ core.notice(`Label "${labelName}" added. ${reason}`);
290+ return true;
291+ }
292+ core.notice(`Label "${labelName}" already present.`);
293+ return false;
294+ }
295+
296+ core.notice(`No matching terms found for label "${labelName}".`);
297+ return false;
298+ }
299+
300+ // Process all configured labels
301+ const processLabels = Object.entries(labelConfig)
302+ .map(([labelName, config]) => processLabel(labelName, config));
303+ const labelsAdded = await Promise.all(processLabels);
304+ const numLabelsAdded = labelsAdded.reduce((x, y) => x + y, 0);
305+ core.notice(`Processing complete. ${numLabelsAdded} label(s) added.`);
0 commit comments