Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 269 additions & 29 deletions .github/scripts/issue_scope_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
const normalizeNewlines = (value) => String(value || '').replace(/\r\n/g, '\n');
const stripBlockquotePrefixes = (value) =>
String(value || '').replace(/^[ \t]*>+[ \t]?/gm, '');
const escapeRegExp = (value) => String(value ?? '').replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
const LIST_ITEM_REGEX = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;

const SECTION_DEFS = [
{ key: 'scope', label: 'Scope', aliases: ['Scope', 'Issue Scope', 'Why', 'Background', 'Context', 'Overview'], optional: true },
Expand Down Expand Up @@ -36,6 +36,7 @@ const PR_META_FALLBACK_PLACEHOLDERS = {
};

const CHECKBOX_SECTIONS = new Set(['tasks', 'acceptance']);
const ACCEPTANCE_CUE_REGEX = /(acceptance|definition of done|done criteria)/i;

function normaliseSectionContent(sectionKey, content) {
const trimmed = String(content || '').trim();
Expand Down Expand Up @@ -84,7 +85,7 @@ function normaliseChecklist(content) {
const lines = raw.split('\n');
let mutated = false;
const updated = lines.map((line) => {
const match = line.match(/^(\s*)([-*])\s+(.*)$/);
const match = line.match(LIST_ITEM_REGEX);
if (!match) {
return line;
}
Expand All @@ -104,6 +105,239 @@ function normaliseChecklist(content) {
return mutated ? updated.join('\n') : raw;
}

function stripHeadingMarkers(rawLine) {
if (!rawLine) {
return '';
}
let text = String(rawLine).trim();
if (!text) {
return '';
}
text = text.replace(/^#{1,6}\s+/, '');
text = text.replace(/\s*:\s*$/, '');

const boldMatch = text.match(/^(?:\*\*|__)(.+)(?:\*\*|__)$/);
if (boldMatch) {
text = boldMatch[1].trim();
}

text = text.replace(/\s*:\s*$/, '');
return text.trim();
}

function extractHeadingLabel(rawLine) {
const cleaned = stripHeadingMarkers(rawLine);
if (!cleaned) {
const listMatch = String(rawLine || '').match(LIST_ITEM_REGEX);
if (!listMatch) {
return '';
}
const remainder = listMatch[3]?.trim() || '';
if (!remainder || /^\[[ xX]\]/.test(remainder)) {
return '';
}
return stripHeadingMarkers(remainder);
}
return cleaned;
}

function extractListBlocks(lines) {
const blocks = [];
let current = [];

const flush = () => {
if (current.length) {
const block = current.join('\n').trim();
if (block) {
blocks.push(block);
}
current = [];
}
};

for (const line of lines) {
if (LIST_ITEM_REGEX.test(line)) {
current.push(line);
continue;
}
if (current.length) {
if (!line.trim()) {
current.push(line);
continue;
}
flush();
}
}
flush();

return blocks;
}

function extractListBlocksWithOffsets(lines) {
const blocks = [];
let current = [];
let blockStart = null;
let blockEnd = null;
let offset = 0;

const flush = () => {
if (!current.length) {
return;
}
const content = current.join('\n').trim();
if (content) {
blocks.push({ start: blockStart, end: blockEnd, content });
}
current = [];
blockStart = null;
blockEnd = null;
};

for (const line of lines) {
const isList = LIST_ITEM_REGEX.test(line);
if (isList) {
if (!current.length) {
blockStart = offset;
}
current.push(line);
blockEnd = offset + line.length;
} else if (current.length) {
if (!line.trim()) {
current.push(line);
blockEnd = offset + line.length;
} else {
flush();
}
}
offset += line.length + 1;
}
flush();

return blocks;
}

function removeTrailingBlock(content, block) {
const trimmedContent = String(content || '').trim();
const trimmedBlock = String(block || '').trim();
if (!trimmedContent || !trimmedBlock) {
return content;
}
if (!trimmedContent.endsWith(trimmedBlock)) {
return content;
}
const index = trimmedContent.lastIndexOf(trimmedBlock);
if (index === -1) {
return content;
}
return trimmedContent.slice(0, index).trimEnd();
}

function blockInsideRange(block, range) {
if (!range) {
return false;
}
return block.start >= range.start && block.end <= range.end;
}

function hasAcceptanceCue(content) {
const lines = String(content || '').split('\n');
return lines.some((line) => {
if (LIST_ITEM_REGEX.test(line)) {
return false;
}
const cleaned = stripHeadingMarkers(line);
if (!cleaned) {
return false;
}
return ACCEPTANCE_CUE_REGEX.test(cleaned);
});
}

function inferSectionsFromLists(segment) {
const sections = { scope: '', tasks: '', acceptance: '' };
const lines = String(segment || '').split('\n');
const firstListIndex = lines.findIndex((line) => LIST_ITEM_REGEX.test(line));
if (firstListIndex === -1) {
return sections;
}

const preListText = lines.slice(0, firstListIndex).join('\n').trim();
if (preListText) {
sections.scope = preListText;
}

const listBlocks = extractListBlocks(lines.slice(firstListIndex));
if (listBlocks.length > 0) {
sections.tasks = listBlocks[0];
}
if (listBlocks.length > 1) {
sections.acceptance = listBlocks[1];
}

return sections;
}

function applyListFallbacks({ segment, sections, listBlocks, ranges }) {
const updated = { ...sections };
const tasksMissing = !String(updated.tasks || '').trim();
const acceptanceMissing = !String(updated.acceptance || '').trim();

if (!tasksMissing && !acceptanceMissing) {
return updated;
}

if (tasksMissing && String(updated.scope || '').trim()) {
const inferred = inferSectionsFromLists(updated.scope);
if (inferred.tasks) {
updated.tasks = inferred.tasks;
updated.scope = inferred.scope || '';
if (acceptanceMissing && inferred.acceptance) {
updated.acceptance = inferred.acceptance;
}
}
}

const acceptanceStillMissing = !String(updated.acceptance || '').trim();
if (acceptanceStillMissing && String(updated.tasks || '').trim()) {
const taskBlocks = extractListBlocks(String(updated.tasks || '').split('\n'));
if (taskBlocks.length > 1 && hasAcceptanceCue(updated.tasks)) {
const acceptanceBlock = taskBlocks[taskBlocks.length - 1];
updated.acceptance = acceptanceBlock;
updated.tasks = removeTrailingBlock(updated.tasks, acceptanceBlock);
}
}

const tasksStillMissing = !String(updated.tasks || '').trim();
if (tasksStillMissing && listBlocks.length) {
const acceptanceRange = ranges.acceptance;
const candidates = listBlocks.filter((block) => !blockInsideRange(block, acceptanceRange));
if (candidates.length) {
if (acceptanceRange) {
const before = candidates.filter((block) => block.end <= acceptanceRange.start);
updated.tasks = (before.length ? before[before.length - 1] : candidates[0]).content;
} else {
updated.tasks = candidates[0].content;
}
}
}

const acceptanceStillMissingAfter = !String(updated.acceptance || '').trim();
if (acceptanceStillMissingAfter && listBlocks.length) {
const tasksRange = ranges.tasks;
const candidates = listBlocks.filter((block) => !blockInsideRange(block, tasksRange));
if (candidates.length) {
if (tasksRange) {
const after = candidates.filter((block) => block.start >= tasksRange.end);
updated.acceptance = (after.length ? after[0] : candidates[candidates.length - 1]).content;
} else {
updated.acceptance = candidates[candidates.length - 1].content;
}
}
}

return updated;
}

function collectSections(source) {
const normalized = stripBlockquotePrefixes(normalizeNewlines(source));
if (!normalized.trim()) {
Expand All @@ -120,17 +354,6 @@ function collectSections(source) {
segment = normalized.slice(startIndex + startMarker.length, endIndex);
}

const headingLabelPattern = SECTION_DEFS
.flatMap((section) => section.aliases)
.map((title) => escapeRegExp(title))
.join('|');

// Match headings that may be markdown headers (# H), bold (**H**), or plain text (with optional colon).
const headingRegex = new RegExp(
`^\\s*(?:#{1,6}\\s+|\\*\\*)?(${headingLabelPattern})(?:\\*\\*|:)?\\s*$`,
'gim'
);

const aliasLookup = SECTION_DEFS.reduce((acc, section) => {
section.aliases.forEach((alias) => {
acc[alias.toLowerCase()] = section;
Expand All @@ -139,21 +362,25 @@ function collectSections(source) {
}, {});

const headings = [];
let match;
while ((match = headingRegex.exec(segment)) !== null) {
const matchedLabel = (match[1] || '').trim();
const title = matchedLabel.toLowerCase();
if (!title || !aliasLookup[title]) {
continue;
const lines = segment.split('\n');
const listBlocks = extractListBlocksWithOffsets(lines);
let offset = 0;
for (const line of lines) {
const matchedLabel = extractHeadingLabel(line);
if (matchedLabel) {
const title = matchedLabel.toLowerCase();
if (aliasLookup[title]) {
const section = aliasLookup[title];
headings.push({
title: section.key,
label: section.label,
index: offset,
length: line.length,
matchedLabel,
});
}
}
const section = aliasLookup[title];
headings.push({
title: section.key,
label: section.label,
index: match.index,
length: match[0].length,
matchedLabel,
});
offset += line.length + 1;
}

const extracted = SECTION_DEFS.reduce((acc, section) => {
Expand All @@ -164,9 +391,20 @@ function collectSections(source) {
acc[section.key] = section.label;
return acc;
}, {});
const ranges = SECTION_DEFS.reduce((acc, section) => {
acc[section.key] = null;
return acc;
}, {});

if (headings.length === 0) {
return { segment, sections: extracted, labels };
const inferred = inferSectionsFromLists(segment);
const merged = {
...extracted,
...Object.fromEntries(
Object.entries(inferred).filter(([, value]) => String(value || '').trim())
),
};
return { segment, sections: merged, labels };
}

for (const section of SECTION_DEFS) {
Expand All @@ -189,9 +427,11 @@ function collectSections(source) {
const content = normalizeNewlines(segment.slice(contentStart, contentEnd)).trim();
extracted[section.key] = content;
labels[section.key] = header.matchedLabel?.trim() || canonicalTitle;
ranges[section.key] = { start: contentStart, end: contentEnd };
}

return { segment, sections: extracted, labels };
const sections = applyListFallbacks({ segment, sections: extracted, listBlocks, ranges });
return { segment, sections, labels };
}

/**
Expand Down
Loading
Loading