also looking at this
fix!: make strategies and labels behave as documented
#1399
7 files
+187
-27
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -254,7 +254,7 @@ jobs: | ||
| 254 | 254 | echo "" >> $GITHUB_STEP_SUMMARY |
| 255 | 255 | |
| 256 | 256 | if [ "$" = "schedule" ]; then |
| 257 | echo "⏰ **Scheduled Check**: Automatically checks every minute" >> $GITHUB_STEP_SUMMARY | |
| 257 | echo "⏰ **Scheduled Check**: Daily cleanup tick — rebase requests are handled on PR edit, in real time" >> $GITHUB_STEP_SUMMARY | |
| 258 | 258 | else |
| 259 | 259 | echo "🖱️ **Manual Check**: Manually triggered from Actions tab" >> $GITHUB_STEP_SUMMARY |
| 260 | 260 | fi |
| @@ -27,7 +27,7 @@ import { appendUpgradeReport } from './upgrades/wire' | ||
| 27 | 27 | import { EventBus } from './events/bus' |
| 28 | 28 | import { createSinks } from './events/sinks' |
| 29 | 29 | import type { PackageRule } from './rules/engine' |
| 30 | import { applyRules, groupsToRules, mergeGroupEffects, resolveRuleEffects } from './rules/engine' | |
| 30 | import { applyRules, groupsToRules, mergeGroupEffects, resolveRuleEffects, strategyAllows } from './rules/engine' | |
| 31 | 31 | import { applyTemplate, templateTokensForGroup } from './pr/templates' |
| 32 | 32 | import { RegistryClient } from './registry/registry-client' |
| 33 | 33 | import { PackageScanner } from './scanner/package-scanner' |
| @@ -363,6 +363,30 @@ export class Buddy { | ||
| 363 | 363 | return merged.length > 0 ? merged : undefined |
| 364 | 364 | } |
| 365 | 365 | |
| 366 | /** | |
| 367 | * Every label a pull request for this group should carry. | |
| 368 | * | |
| 369 | * Three sources union here: the automatic set derived from the update types | |
| 370 | * and manifests in the group, whatever `pullRequest.labels` declares, and the | |
| 371 | * labels any matching rule asked for. | |
| 372 | * | |
| 373 | * Centralised because the five call sites had drifted — only the creation | |
| 374 | * path folded in rule labels, so refreshing an existing pull request quietly | |
| 375 | * dropped them, and `pullRequest.labels` was read by nothing at all despite | |
| 376 | * being declared and validated. | |
| 377 | * | |
| 378 | * @param group - Updates the pull request will contain | |
| 379 | * @param prGenerator - Generator supplying the automatic labels | |
| 380 | * @returns Labels to set on the pull request | |
| 381 | */ | |
| 382 | private labelsFor(group: UpdateGroup, prGenerator: PullRequestGenerator): string[] { | |
| 383 | return [...new Set([ | |
| 384 | ...prGenerator.generateLabels(group), | |
| 385 | ...(this.config.pullRequest?.labels ?? []), | |
| 386 | ...this.groupEffectsFor(group.updates).labels, | |
| 387 | ])] | |
| 388 | } | |
| 389 | ||
| 366 | 390 | /** Every rule governing this run, legacy groups compiled in first. */ |
| 367 | 391 | private activeRules(): PackageRule[] { |
| 368 | 392 | return [...groupsToRules(this.config.packages?.groups), ...(this.config.packages?.rules ?? [])] |
| @@ -795,7 +819,7 @@ export class Buddy { | ||
| 795 | 819 | } |
| 796 | 820 | |
| 797 | 821 | // Generate dynamic labels for the update |
| 798 | const dynamicLabels = prGenerator.generateLabels(group) | |
| 822 | const dynamicLabels = this.labelsFor(group, prGenerator) | |
| 799 | 823 | |
| 800 | 824 | // Update existing PR with new content |
| 801 | 825 | await gitProvider.updatePullRequest(existingPR.number, { |
| @@ -841,7 +865,7 @@ export class Buddy { | ||
| 841 | 865 | await gitProvider.commitChanges(branchName, this.commitMessageFor(group, '(updated)'), packageJsonUpdates, this.config.repository.baseBranch || 'main') |
| 842 | 866 | } |
| 843 | 867 | |
| 844 | const dynamicLabels = prGenerator.generateLabels(group) | |
| 868 | const dynamicLabels = this.labelsFor(group, prGenerator) | |
| 845 | 869 | await gitProvider.updatePullRequest(existingPRForBranch.number, { |
| 846 | 870 | title: prTitle, |
| 847 | 871 | body: prBody, |
| @@ -887,7 +911,7 @@ export class Buddy { | ||
| 887 | 911 | } |
| 888 | 912 | |
| 889 | 913 | await gitProvider.reopenPullRequest!(recentlyClosed.number) |
| 890 | const dynamicLabels = prGenerator.generateLabels(group) | |
| 914 | const dynamicLabels = this.labelsFor(group, prGenerator) | |
| 891 | 915 | await gitProvider.updatePullRequest(recentlyClosed.number, { |
| 892 | 916 | title: prTitle, |
| 893 | 917 | body: prBody, |
| @@ -958,7 +982,7 @@ export class Buddy { | ||
| 958 | 982 | // Reopen and update the existing PR |
| 959 | 983 | await gitProvider.reopenPullRequest!(recentlyClosed.number) |
| 960 | 984 | // Point the reopened PR at the new branch (in case branch name changed) |
| 961 | const dynamicLabels = prGenerator.generateLabels(group) | |
| 985 | const dynamicLabels = this.labelsFor(group, prGenerator) | |
| 962 | 986 | await gitProvider.updatePullRequest(recentlyClosed.number, { |
| 963 | 987 | title: prTitle, |
| 964 | 988 | body: prBody, |
| @@ -1054,10 +1078,10 @@ export class Buddy { | ||
| 1054 | 1078 | // Commit changes (Renovate-style: branch is created from base, changes applied fresh) |
| 1055 | 1079 | await gitProvider.commitChanges(branchName, this.commitMessageFor(group), packageJsonUpdates, this.config.repository.baseBranch || 'main') |
| 1056 | 1080 | |
| 1057 | // Generate dynamic labels based on update types and package types, | |
| 1058 | // then union whatever the matching rules asked for. | |
| 1081 | // Reviewers and assignees still union here; labels are resolved by | |
| 1082 | // `labelsFor`, which every pull-request path shares. | |
| 1059 | 1083 | const groupEffects = this.groupEffectsFor(group.updates) |
| 1060 | const dynamicLabels = [...new Set([...prGenerator.generateLabels(group), ...groupEffects.labels])] | |
| 1084 | const dynamicLabels = this.labelsFor(group, prGenerator) | |
| 1061 | 1085 | |
| 1062 | 1086 | // Create pull request |
| 1063 | 1087 | const pr = await gitProvider.createPullRequest({ |
| @@ -1863,7 +1887,16 @@ export class Buddy { | ||
| 1863 | 1887 | } |
| 1864 | 1888 | |
| 1865 | 1889 | /** |
| 1866 | * Filter updates by strategy | |
| 1890 | * Filter updates by strategy. | |
| 1891 | * | |
| 1892 | * Defers to `strategyAllows`, the same predicate the rules engine uses for a | |
| 1893 | * rule-level `strategy`, so `packages.strategy` and a per-rule strategy can | |
| 1894 | * never again mean different things on the same set of updates. | |
| 1895 | * | |
| 1896 | * @param updates - Updates to filter | |
| 1897 | * @param strategy - Ceiling on semver impact: `all` admits everything, | |
| 1898 | * `major` only majors, `minor` minors and patches, `patch` only patches | |
| 1899 | * @returns The updates the strategy permits | |
| 1867 | 1900 | */ |
| 1868 | 1901 | private filterUpdatesByStrategy( |
| 1869 | 1902 | updates: PackageUpdate[], |
| @@ -1872,18 +1905,7 @@ export class Buddy { | ||
| 1872 | 1905 | if (strategy === 'all') |
| 1873 | 1906 | return updates |
| 1874 | 1907 | |
| 1875 | return updates.filter((update) => { | |
| 1876 | switch (strategy) { | |
| 1877 | case 'major': | |
| 1878 | return update.updateType === 'major' | |
| 1879 | case 'minor': | |
| 1880 | return update.updateType === 'major' || update.updateType === 'minor' | |
| 1881 | case 'patch': | |
| 1882 | return true // Include all types for patch strategy | |
| 1883 | default: | |
| 1884 | return true | |
| 1885 | } | |
| 1886 | }) | |
| 1908 | return updates.filter(update => strategyAllows(strategy, update.updateType)) | |
| 1887 | 1909 | } |
| 1888 | 1910 | |
| 1889 | 1911 | /** |
| @@ -46,12 +46,10 @@ export const HELP_TEXT = `**Buddy commands** | ||
| 46 | 46 | | \`@buddy review\` | Review new changes since the last review | |
| 47 | 47 | | \`@buddy full-review\` | Review the whole diff again, ignoring previous findings | |
| 48 | 48 | | \`@buddy summary\` | Post a fresh summary without inline findings | |
| 49 | | \`@buddy resolve\` | Resolve the review threads this bot opened | | |
| 50 | 49 | | \`@buddy pause\` / \`resume\` | Stop or restart reviewing this pull request | |
| 51 | 50 | | \`@buddy rebase\` | Rebase a dependency update pull request | |
| 52 | 51 | | \`@buddy merge\` | Re-check auto-merge conditions now | |
| 53 | 52 | | \`@buddy fix-ci\` | Diagnose and try to fix failing checks | |
| 54 | | \`@buddy plan\` | Produce an implementation plan for an issue | | |
| 55 | 53 | | \`@buddy remember <text>\` | Record a durable note for future runs | |
| 56 | 54 | | \`@buddy help\` | Show this table | |
| 57 | 55 | |
| @@ -283,8 +283,25 @@ export function mergeGroupEffects(effects: ResolvedEffects[]): { | ||
| 283 | 283 | } |
| 284 | 284 | } |
| 285 | 285 | |
| 286 | /** Whether a strategy permits an update of this type. */ | |
| 287 | function strategyAllows(strategy: NonNullable<PackageRule['strategy']>, updateType: 'major' | 'minor' | 'patch'): boolean { | |
| 286 | /** | |
| 287 | * Whether a strategy permits an update of this type. | |
| 288 | * | |
| 289 | * A strategy names the greatest semver impact a run may propose, so each one | |
| 290 | * admits its own update type and everything gentler beneath it. Exported so the | |
| 291 | * global filter in `src/buddy.ts` resolves the same question through the same | |
| 292 | * function — the two carried separate switches once, and disagreed, which made | |
| 293 | * `packages.strategy` and a rule-level `strategy` mean different things. | |
| 294 | * | |
| 295 | * @param strategy - Ceiling on semver impact | |
| 296 | * @param updateType - The impact of the update being considered | |
| 297 | * @returns Whether the update is within the ceiling | |
| 298 | * @example | |
| 299 | * ```ts | |
| 300 | * strategyAllows('minor', 'patch') // true | |
| 301 | * strategyAllows('patch', 'major') // false | |
| 302 | * ``` | |
| 303 | */ | |
| 304 | export function strategyAllows(strategy: NonNullable<PackageRule['strategy']>, updateType: 'major' | 'minor' | 'patch'): boolean { | |
| 288 | 305 | switch (strategy) { |
| 289 | 306 | case 'all': |
| 290 | 307 | return true |
| @@ -1703,7 +1703,7 @@ ${generateComposerSetupSteps()} | ||
| 1703 | 1703 | echo "" >> \$GITHUB_STEP_SUMMARY |
| 1704 | 1704 | |
| 1705 | 1705 | if [ "\$" = "schedule" ]; then |
| 1706 | echo "⏰ **Scheduled Check**: Automatically checks every minute" >> \$GITHUB_STEP_SUMMARY | |
| 1706 | echo "⏰ **Scheduled Check**: Daily cleanup tick — rebase requests are handled on PR edit, in real time" >> \$GITHUB_STEP_SUMMARY | |
| 1707 | 1707 | else |
| 1708 | 1708 | echo "🖱️ **Manual Check**: Manually triggered from Actions tab" >> \$GITHUB_STEP_SUMMARY |
| 1709 | 1709 | fi |
| @@ -8,6 +8,7 @@ import { | ||
| 8 | 8 | mergeGroupEffects, |
| 9 | 9 | resolveRuleEffects, |
| 10 | 10 | ruleMatches, |
| 11 | strategyAllows, | |
| 11 | 12 | } from '../src/rules/engine' |
| 12 | 13 | |
| 13 | 14 | function makeUpdate(overrides: Partial<PackageUpdate> = {}): PackageUpdate { |
| @@ -214,3 +215,39 @@ describe('legacy group compilation', () => { | ||
| 214 | 215 | expect(groupsToRules()).toEqual([]) |
| 215 | 216 | }) |
| 216 | 217 | }) |
| 218 | ||
| 219 | describe('strategy semantics', () => { | |
| 220 | const types = ['major', 'minor', 'patch'] as const | |
| 221 | ||
| 222 | it('success case - each strategy admits its own type and everything gentler', () => { | |
| 223 | expect(types.filter(t => strategyAllows('all', t))).toEqual(['major', 'minor', 'patch']) | |
| 224 | expect(types.filter(t => strategyAllows('major', t))).toEqual(['major']) | |
| 225 | expect(types.filter(t => strategyAllows('minor', t))).toEqual(['minor', 'patch']) | |
| 226 | expect(types.filter(t => strategyAllows('patch', t))).toEqual(['patch']) | |
| 227 | }) | |
| 228 | ||
| 229 | it('edge case - patch never admits a major', () => { | |
| 230 | // `patch` once returned true for every update type, so the safest-sounding | |
| 231 | // setting silently proposed breaking changes. | |
| 232 | expect(strategyAllows('patch', 'major')).toBe(false) | |
| 233 | }) | |
| 234 | ||
| 235 | it('edge case - minor never admits a major', () => { | |
| 236 | // `minor` once admitted majors while excluding patches, inverting it. | |
| 237 | expect(strategyAllows('minor', 'major')).toBe(false) | |
| 238 | expect(strategyAllows('minor', 'patch')).toBe(true) | |
| 239 | }) | |
| 240 | ||
| 241 | it('success case - a rule-level strategy filters the same way as the predicate', () => { | |
| 242 | // The global filter in `src/buddy.ts` and the rules engine each carried | |
| 243 | // their own switch and disagreed. Both now resolve through this predicate. | |
| 244 | for (const strategy of ['all', 'major', 'minor', 'patch'] as const) { | |
| 245 | const kept = applyRules( | |
| 246 | types.map(t => makeUpdate({ updateType: t })), | |
| 247 | [{ matchPackages: ['*'], strategy }], | |
| 248 | ).map(r => r.update.updateType) | |
| 249 | ||
| 250 | expect(kept).toEqual(types.filter(t => strategyAllows(strategy, t))) | |
| 251 | } | |
| 252 | }) | |
| 253 | }) | |
| @@ -0,0 +1,86 @@ | ||
| 1 | import type { BuddyConfig, PackageUpdate, UpdateGroup } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { Buddy } from '../src/buddy' | |
| 4 | import { PullRequestGenerator } from '../src/pr/pr-generator' | |
| 5 | ||
| 6 | /** | |
| 7 | * Labels reaching a pull request come from three places, and each one was | |
| 8 | * broken or missing at some point: the automatic set, `pullRequest.labels` | |
| 9 | * (declared and validated but read by nothing), and rule effects (folded in | |
| 10 | * only on the creation path, so refreshing a pull request dropped them). | |
| 11 | */ | |
| 12 | describe('pull request labels', () => { | |
| 13 | const update: PackageUpdate = { | |
| 14 | name: 'react', | |
| 15 | currentVersion: '18.0.0', | |
| 16 | newVersion: '18.0.1', | |
| 17 | updateType: 'patch', | |
| 18 | dependencyType: 'dependencies', | |
| 19 | file: 'package.json', | |
| 20 | } | |
| 21 | ||
| 22 | const group: UpdateGroup = { | |
| 23 | name: 'Patch Updates', | |
| 24 | updateType: 'patch', | |
| 25 | title: 'chore(deps): update react to v18.0.1', | |
| 26 | body: '', | |
| 27 | updates: [update], | |
| 28 | } | |
| 29 | ||
| 30 | const baseConfig: BuddyConfig = { | |
| 31 | repository: { provider: 'github', owner: 'stacksjs', name: 'buddy' }, | |
| 32 | } | |
| 33 | ||
| 34 | /** Resolve labels the way every pull-request path now does. */ | |
| 35 | function labelsFor(config: BuddyConfig): string[] { | |
| 36 | const buddy = new Buddy(config) | |
| 37 | // @ts-expect-error - accessing private method for testing | |
| 38 | return buddy.labelsFor(group, new PullRequestGenerator(config)) | |
| 39 | } | |
| 40 | ||
| 41 | it('success case - always includes the automatic set', () => { | |
| 42 | const labels = labelsFor(baseConfig) | |
| 43 | expect(labels).toContain('dependencies') | |
| 44 | expect(labels).toContain('patch') | |
| 45 | expect(labels).toContain('npm') | |
| 46 | }) | |
| 47 | ||
| 48 | it('success case - includes labels declared in pullRequest.labels', () => { | |
| 49 | // This key was declared, validated and documented, but the live path never | |
| 50 | // read it, so a configured label silently never reached a pull request. | |
| 51 | const labels = labelsFor({ ...baseConfig, pullRequest: { labels: ['deps', 'automated'] } }) | |
| 52 | expect(labels).toContain('deps') | |
| 53 | expect(labels).toContain('automated') | |
| 54 | expect(labels).toContain('dependencies') | |
| 55 | }) | |
| 56 | ||
| 57 | it('success case - includes labels a matching rule asked for', () => { | |
| 58 | const labels = labelsFor({ | |
| 59 | ...baseConfig, | |
| 60 | packages: { strategy: 'all', rules: [{ matchPackages: ['react'], labels: ['frontend'] }] }, | |
| 61 | }) | |
| 62 | expect(labels).toContain('frontend') | |
| 63 | }) | |
| 64 | ||
| 65 | it('success case - unions all three sources without duplicating', () => { | |
| 66 | const labels = labelsFor({ | |
| 67 | ...baseConfig, | |
| 68 | pullRequest: { labels: ['dependencies', 'deps'] }, | |
| 69 | packages: { strategy: 'all', rules: [{ matchPackages: ['react'], labels: ['deps', 'frontend'] }] }, | |
| 70 | }) | |
| 71 | ||
| 72 | expect(labels).toContain('deps') | |
| 73 | expect(labels).toContain('frontend') | |
| 74 | // `dependencies` is automatic and also configured; it must appear once. | |
| 75 | expect(labels.filter(label => label === 'dependencies')).toHaveLength(1) | |
| 76 | expect(labels.filter(label => label === 'deps')).toHaveLength(1) | |
| 77 | }) | |
| 78 | ||
| 79 | it('edge case - a rule that does not match contributes nothing', () => { | |
| 80 | const labels = labelsFor({ | |
| 81 | ...baseConfig, | |
| 82 | packages: { strategy: 'all', rules: [{ matchPackages: ['vue'], labels: ['should-not-appear'] }] }, | |
| 83 | }) | |
| 84 | expect(labels).not.toContain('should-not-appear') | |
| 85 | }) | |
| 86 | }) | |