@@ -396,6 +396,7 @@ jobs:
396396 const RELEASE_RELEVANT_LABELS = ['api', 'breaking-change', 'bug', 'compatibility', 'database', 'enhancement', 'feature-flag', 'migration', 'performance', 'runtime', 'schema', 'security'];
397397 const SECONDARY_LABELS = ['chore', 'ci', 'cleanup', 'config', 'dependencies', 'documentation', 'dx', 'formatting', 'github', 'lint', 'quality', 'refactor', 'style', 'test', 'tooling', 'workflow'];
398398 const BOT_COMMENT_SIGNATURE = '<!-- autobot-ai-summary -->';
399+ const MILESTONE_COMMENT_SIGNATURE = '<!-- autobot-milestone-update -->';
399400 const labelDefinitions = {
400401 'bug': { color: 'd73a4a', description: "Something isn't working" },
401402 'enhancement': { color: 'a2eeef', description: "New feature or request" },
@@ -498,6 +499,9 @@ jobs:
498499 if (!match) return { major: 0, minor: 0, patch: 1 };
499500 return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
500501 }
502+ function isVersionTag(rawVersion) {
503+ return /^v?\d+\.\d+\.\d+$/.test(String(rawVersion || '').trim());
504+ }
501505 function formatVersionTag(version) {
502506 return `v${version.major}.${version.minor}.${version.patch}`;
503507 }
@@ -511,6 +515,23 @@ jobs:
511515 return maxBump(bump, next);
512516 }, 'none');
513517 }
518+ function mergeSyncLabels(previousLabels, predictedLabels) {
519+ const stableLabels = [...new Set(previousLabels.map(normalizeLabelName).filter((label) => VALID_LABELS.has(label)))];
520+ let currentBump = bumpForLabels(stableLabels);
521+ const predictedSet = new Set(predictedLabels.map(normalizeLabelName).filter((label) => VALID_LABELS.has(label)));
522+
523+ for (const label of VERSION_SENSITIVE_LABELS) {
524+ if (!predictedSet.has(label) || stableLabels.includes(label)) continue;
525+ const nextBump = VERSION_BUMP_BY_LABEL[label] || 'none';
526+ const raisesVersionSeverity = BUMP_ORDER[nextBump] > BUMP_ORDER[currentBump];
527+ const isUrgent = URGENT_SYNC_LABELS.includes(label);
528+ if (!raisesVersionSeverity && !isUrgent) continue;
529+ stableLabels.push(label);
530+ currentBump = maxBump(currentBump, nextBump);
531+ }
532+
533+ return trimLowSignalLabels(stableLabels).slice(0, MAX_AI_LABELS);
534+ }
514535 function computeTargetVersion(baseVersion, bumpType) {
515536 const parsed = parseVersionTag(baseVersion);
516537 if (bumpType === 'major') return formatVersionTag({ major: parsed.major + 1, minor: 0, patch: 0 });
@@ -583,7 +604,13 @@ jobs:
583604 }
584605 async function getOrCreateMilestone() {
585606 const milestones = await github.rest.issues.listMilestones({ owner, repo, state: 'open', sort: 'due_on', direction: 'asc' });
586- if (milestones.data.length > 0) return milestones.data[0];
607+ const versionedMilestones = milestones.data.filter((milestone) => isVersionTag(milestone.title));
608+ if (versionedMilestones.length > 0) {
609+ return versionedMilestones.reduce((selected, current) => {
610+ if (!selected) return current;
611+ return compareVersions(current.title, selected.title) > 0 ? current : selected;
612+ }, null);
613+ }
587614 let nextVersion = 'v0.0.1';
588615 try {
589616 const releases = await github.rest.repos.listReleases({ owner, repo });
@@ -597,6 +624,43 @@ jobs:
597624 const created = await github.rest.issues.createMilestone({ owner, repo, title: nextVersion });
598625 return created.data;
599626 }
627+ async function getOrCreateMilestoneByTitle(title) {
628+ const milestones = await github.rest.issues.listMilestones({ owner, repo, state: 'open', sort: 'due_on', direction: 'asc' });
629+ const existing = milestones.data.find((milestone) => String(milestone.title || '').trim() === title);
630+ if (existing) return existing;
631+ const created = await github.rest.issues.createMilestone({ owner, repo, title });
632+ return created.data;
633+ }
634+ async function seedMilestoneMetadata(milestone, baseVersion, managedTitle) {
635+ const nextDescription = buildMilestoneMetadataDescription(milestone.description || '', baseVersion, managedTitle);
636+ if (nextDescription === String(milestone.description || '').trim()) return milestone;
637+ const updated = await github.rest.issues.updateMilestone({ owner, repo, milestone_number: milestone.number, description: nextDescription });
638+ return updated.data;
639+ }
640+ async function listManagedSemverMilestones(targetMilestone) {
641+ if (!isVersionTag(targetMilestone.title)) return [targetMilestone];
642+ const milestones = await github.rest.issues.listMilestones({ owner, repo, state: 'open', sort: 'due_on', direction: 'asc' });
643+ return milestones.data.filter((milestone) => isVersionTag(milestone.title) && compareVersions(milestone.title, targetMilestone.title) <= 0);
644+ }
645+ async function listReleaseItemsForMilestones(milestones) {
646+ const releaseItems = [];
647+ for (const milestone of milestones) {
648+ const milestoneItems = await github.paginate(github.rest.issues.listForRepo, { owner, repo, milestone: milestone.number, state: 'all' });
649+ releaseItems.push(...milestoneItems.filter((item) => item.pull_request && item.labels.some((label) => RELEASE_RELEVANT_LABELS.includes(label.name))));
650+ }
651+ return releaseItems;
652+ }
653+ function getItemLabelNames(item) {
654+ return (item.labels || []).map((label) => normalizeLabelName(label.name));
655+ }
656+ function resolveRequiredBump(releaseItems, currentLabelNames) {
657+ const releaseBump = releaseItems.reduce((bump, item) => {
658+ const itemLabels = getItemLabelNames(item);
659+ const itemBump = bumpForLabels(itemLabels);
660+ return maxBump(bump, itemBump);
661+ }, bumpForLabels(currentLabelNames));
662+ return releaseBump === 'none' ? 'patch' : releaseBump;
663+ }
600664 function parseAILabels(raw) {
601665 if (!raw) return [];
602666 try {
@@ -624,6 +688,14 @@ jobs:
624688 const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issueNumber });
625689 return comments.find(comment => comment.user.type === 'Bot' && comment.body.includes(BOT_COMMENT_SIGNATURE));
626690 }
691+ async function getExistingBotCommentForIssue(targetIssueNumber) {
692+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: targetIssueNumber });
693+ return comments.find((comment) => comment.user.type === 'Bot' && comment.body.includes(BOT_COMMENT_SIGNATURE));
694+ }
695+ async function getExistingMilestoneComment() {
696+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: issueNumber });
697+ return comments.find(comment => comment.user.type === 'Bot' && comment.body.includes(MILESTONE_COMMENT_SIGNATURE));
698+ }
627699 async function upsertBotComment(body, metadata) {
628700 const existing = await getExistingBotComment();
629701 const MAX_COMMENT_CHARS = 60000;
@@ -633,6 +705,56 @@ jobs:
633705 if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: fullBody });
634706 else await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: fullBody });
635707 }
708+ async function upsertMilestoneComment(body, metadata) {
709+ const existing = await getExistingMilestoneComment();
710+ const fullBody = MILESTONE_COMMENT_SIGNATURE + '\n' + `<!-- autobot-metadata:${JSON.stringify(metadata || {})} -->` + '\n' + body;
711+ if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: fullBody });
712+ else await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: fullBody });
713+ }
714+ async function selectivelyConsolidateSupersededMilestones(targetMilestone, targetBump, currentIssueNumber) {
715+ if (!isVersionTag(targetMilestone.title)) {
716+ return { milestone: targetMilestone, consolidatedMilestones: [], migratedPullRequests: [], retainedMilestones: [] };
717+ }
718+ const milestones = await github.rest.issues.listMilestones({ owner, repo, state: 'open', sort: 'due_on', direction: 'asc' });
719+ const supersededMilestones = milestones.data.filter((milestone) => {
720+ if (milestone.number === targetMilestone.number) return false;
721+ if (!isVersionTag(milestone.title)) return false;
722+ return compareVersions(milestone.title, targetMilestone.title) <= 0;
723+ });
724+ const consolidatedMilestones = [];
725+ const migratedPullRequests = [];
726+ const retainedMilestones = [];
727+
728+ for (const milestone of supersededMilestones) {
729+ const milestoneItems = await github.paginate(github.rest.issues.listForRepo, { owner, repo, milestone: milestone.number, state: 'all' });
730+ let movableOpenPrCount = 0;
731+ for (const item of milestoneItems) {
732+ if (!item.pull_request) continue;
733+ if (item.state !== 'open') continue;
734+ if (item.number === currentIssueNumber) continue;
735+ const itemLabels = getItemLabelNames(item);
736+ if (!hasReleaseRelevantLabel(itemLabels)) continue;
737+ const itemBump = bumpForLabels(itemLabels);
738+ if (BUMP_ORDER[itemBump] > BUMP_ORDER[targetBump]) continue;
739+ const existingBotCommentForItem = await getExistingBotCommentForIssue(item.number);
740+ if (!existingBotCommentForItem) continue;
741+ await github.rest.issues.update({ owner, repo, issue_number: item.number, milestone: targetMilestone.number });
742+ migratedPullRequests.push({ number: item.number, title: item.title, fromMilestone: milestone.title });
743+ movableOpenPrCount += 1;
744+ }
745+
746+ const remainingItems = await github.paginate(github.rest.issues.listForRepo, { owner, repo, milestone: milestone.number, state: 'all' });
747+ if (remainingItems.length === 0) {
748+ await github.rest.issues.updateMilestone({ owner, repo, milestone_number: milestone.number, state: 'closed' });
749+ consolidatedMilestones.push(milestone.title);
750+ } else {
751+ retainedMilestones.push({ title: milestone.title, remainingCount: remainingItems.length, migratedPullRequestCount: movableOpenPrCount });
752+ }
753+ }
754+
755+ const refreshed = await github.rest.issues.getMilestone({ owner, repo, milestone_number: targetMilestone.number });
756+ return { milestone: refreshed.data, consolidatedMilestones, migratedPullRequests, retainedMilestones };
757+ }
636758 if (['opened', 'synchronize', 'reopened'].includes(payload.action)) {
637759 const aiSummary = (process.env.AI_SUMMARY || '').replace(/\nEND_OF_REPORT\s*$/m, '').trim();
638760 const aiLabelsRaw = process.env.AI_LABELS_RAW || '';
@@ -643,10 +765,7 @@ jobs:
643765 if (isPR && aiLabelsRaw) {
644766 const predictedLabels = trimLowSignalLabels(parseAILabels(aiLabelsRaw)).slice(0, MAX_AI_LABELS);
645767 if (payload.action === 'synchronize') {
646- const previousSet = new Set(previousBotLabels);
647- const predictedSet = new Set(predictedLabels);
648- const urgentAdds = URGENT_SYNC_LABELS.filter((label) => predictedSet.has(label));
649- nextAiLabels = [...new Set([...previousSet, ...urgentAdds])].slice(0, MAX_AI_LABELS);
768+ nextAiLabels = mergeSyncLabels(previousBotLabels, predictedLabels);
650769 } else {
651770 nextAiLabels = predictedLabels;
652771 }
@@ -686,8 +805,31 @@ jobs:
686805 return;
687806 }
688807 const issueHadMilestoneBeforeAutobot = Boolean(freshIssue.data.milestone);
689- const milestone = await getOrCreateMilestone();
690- if (!freshIssue.data.milestone) await github.rest.issues.update({ owner, repo, issue_number: issueNumber, milestone: milestone.number });
808+ const existingMilestone = freshIssue.data.milestone
809+ ? (await github.rest.issues.getMilestone({ owner, repo, milestone_number: freshIssue.data.milestone.number })).data
810+ : null;
811+ if (existingMilestone && !isVersionTag(existingMilestone.title)) {
812+ return;
813+ }
814+ const highestOpenVersionMilestone = await getOrCreateMilestone();
815+ let milestone = existingMilestone || highestOpenVersionMilestone;
816+ if (isVersionTag(highestOpenVersionMilestone.title) && (!existingMilestone || isVersionTag(milestone.title) && compareVersions(highestOpenVersionMilestone.title, milestone.title) > 0)) {
817+ milestone = highestOpenVersionMilestone;
818+ }
819+ const previewMilestoneWithBase = await ensureMilestoneBaseVersion(milestone);
820+ const currentIssueLabels = freshIssue.data.labels.map(label => normalizeLabelName(label.name));
821+ const previewManagedMilestones = await listManagedSemverMilestones(previewMilestoneWithBase.milestone);
822+ const previewReleaseItems = await listReleaseItemsForMilestones(previewManagedMilestones);
823+ const previewRequiredBump = resolveRequiredBump(previewReleaseItems, currentIssueLabels);
824+ const targetVersion = computeTargetVersion(previewMilestoneWithBase.baseVersion, previewRequiredBump);
825+ if (isVersionTag(targetVersion) && compareVersions(targetVersion, milestone.title) > 0) {
826+ milestone = await getOrCreateMilestoneByTitle(targetVersion);
827+ milestone = await seedMilestoneMetadata(milestone, previewMilestoneWithBase.baseVersion, targetVersion);
828+ }
829+ const consolidation = await selectivelyConsolidateSupersededMilestones(milestone, previewRequiredBump, issueNumber);
830+ milestone = consolidation.milestone;
831+ const milestoneChanged = !freshIssue.data.milestone || freshIssue.data.milestone.number !== milestone.number;
832+ if (milestoneChanged) await github.rest.issues.update({ owner, repo, issue_number: issueNumber, milestone: milestone.number });
691833 const items = await github.paginate(github.rest.issues.listForRepo, { owner, repo, milestone: milestone.number, state: 'all' });
692834 const releaseItems = items.filter(item => item.pull_request && item.labels.some(label => RELEASE_RELEVANT_LABELS.includes(label.name)));
693835 const isBreaking = releaseItems.some(item => item.labels.some(label => label.name === 'breaking-change'));
@@ -697,20 +839,41 @@ jobs:
697839 if (!alreadyAlerted) await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body: `🚨 **MAJOR RELEASE ALERT** 🚨\n\n@${owner} This PR triggers a **major** version bump due to breaking changes detected by AI analysis.` });
698840 }
699841 const milestoneWithBase = await ensureMilestoneBaseVersion(milestone);
700- const shouldRespectManualMilestone = milestoneWithBase.manualTitleLocked || (issueHadMilestoneBeforeAutobot && !milestoneWithBase.hadManagedMarker);
842+ const shouldRespectManualMilestone = milestoneWithBase.manualTitleLocked || (issueHadMilestoneBeforeAutobot && !milestoneWithBase.hadManagedMarker && !isVersionTag(milestoneWithBase.milestone.title) );
701843 if (shouldRespectManualMilestone) return;
702- const releaseBump = releaseItems.reduce((bump, item) => {
703- const itemLabels = item.labels.map(label => normalizeLabelName(label.name));
704- const itemBump = bumpForLabels(itemLabels);
705- return maxBump(bump, itemBump);
706- }, 'none');
707- const requiredBump = releaseBump === 'none' && releaseItems.length > 0 ? 'patch' : releaseBump;
844+ const requiredBump = resolveRequiredBump(releaseItems, currentIssueLabels);
708845 const computedTitle = computeTargetVersion(milestoneWithBase.baseVersion, requiredBump);
709846 const newTitle = compareVersions(computedTitle, milestoneWithBase.milestone.title) > 0 ? computedTitle : milestoneWithBase.milestone.title;
710847 if (newTitle !== milestoneWithBase.milestone.title) {
711848 const metadataDescription = buildMilestoneMetadataDescription(milestoneWithBase.milestone.description || milestone.description || '', milestoneWithBase.baseVersion, newTitle);
712849 await github.rest.issues.updateMilestone({ owner, repo, milestone_number: milestone.number, title: newTitle, description: metadataDescription });
713850 }
851+ if (isPR && (milestoneChanged || consolidation.consolidatedMilestones.length > 0 || consolidation.migratedPullRequests.length > 0 || consolidation.retainedMilestones.length > 0 || newTitle !== milestoneWithBase.milestone.title)) {
852+ const previousMilestoneTitle = existingMilestone ? existingMilestone.title : null;
853+ const finalMilestoneTitle = newTitle;
854+ const noteLines = [
855+ "# Autobot — Milestone Update",
856+ "",
857+ milestoneChanged || newTitle !== milestoneWithBase.milestone.title
858+ ? `This PR was moved ${previousMilestoneTitle ? `from **${previousMilestoneTitle}** ` : ""}to **${finalMilestoneTitle}** based on the current semantic-version impact of the PR and the canonical open release milestone.`
859+ : `Autobot kept this PR on **${finalMilestoneTitle}** and selectively consolidated compatible lower semantic-version PRs into it.`,
860+ ""
861+ ];
862+ if (consolidation.migratedPullRequests.length > 0) {
863+ noteLines.push(`Moved compatible PRs: ${consolidation.migratedPullRequests.map((item) => `#${item.number}`).join(", ")}`);
864+ noteLines.push("");
865+ }
866+ if (consolidation.consolidatedMilestones.length > 0) {
867+ noteLines.push(`Closed empty older milestones: ${consolidation.consolidatedMilestones.map((title) => `**${title}**`).join(", ")}`);
868+ noteLines.push("");
869+ }
870+ if (consolidation.retainedMilestones.length > 0) {
871+ noteLines.push(`Retained older milestones for manual review: ${consolidation.retainedMilestones.map((item) => `**${item.title}** (${item.remainingCount} remaining)`).join(", ")}`);
872+ noteLines.push("");
873+ }
874+ noteLines.push("This only moves compatible open AI-managed PRs. Issues, closed items, manual milestones, and incompatible PRs stay where they are.");
875+ await upsertMilestoneComment(noteLines.join('\n'), { milestone: finalMilestoneTitle, previousMilestone: previousMilestoneTitle, consolidatedMilestones: consolidation.consolidatedMilestones, migratedPullRequests: consolidation.migratedPullRequests.map((item) => item.number), retainedMilestones: consolidation.retainedMilestones, targetVersion: finalMilestoneTitle, requiredBump });
876+ }
714877 }
715878 if (payload.action === 'closed') {
716879 if (!payload.pull_request || !payload.pull_request.merged) return;
0 commit comments