Skip to content

Commit 424420d

Browse files
committed
Enhance milestone management and label sync
Add robust semver milestone handling and improved label synchronization. Introduces MILESTONE_COMMENT_SIGNATURE, isVersionTag/format helpers, mergeSyncLabels, and several milestone utilities (getOrCreateMilestoneByTitle, seedMilestoneMetadata, listManagedSemverMilestones, listReleaseItemsForMilestones, resolveRequiredBump). Adds logic to prefer/version-select milestones, compute/create target version milestones, and seed metadata. Implements selective consolidation/migration of compatible open PRs into newer milestones and upserts a milestone comment summarizing migrations/closures/retentions. Also replaces the previous naive label-sync on PR synchronize with mergeSyncLabels to preserve stable labels while promoting urgent/version-sensitive labels.
1 parent 8407176 commit 424420d

File tree

1 file changed

+177
-14
lines changed

1 file changed

+177
-14
lines changed

.github/workflows/autobot.yml

Lines changed: 177 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)