Skip to content

Commit 9a25bdc

Browse files
decyjphrCopilot
andcommitted
Skip redundant app installation churn for unchanged delta
In delta mode, when an app is present in both the previous and current suborg/repo config: - If suborg targeting is unchanged, skip the app entirely (no selection or unselection) — avoids re-adding all suborg repos on unrelated config edits - If targeting changed, emit only the diff (newly targeted repos to add, no-longer targeted repos to remove) instead of re-adding the full set - Repo-level: only select when the app is newly added to the repo config Add unit tests covering the skip, targeting-diff, and org-'all' precedence cases for _buildAppChangesFromDelta. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b731d54 commit 9a25bdc

2 files changed

Lines changed: 122 additions & 14 deletions

File tree

lib/settings.js

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,34 +1594,37 @@ class Settings {
15941594
const currentRepos = await resolveSuborgRepos(currentConfig)
15951595
const previousRepos = await resolveSuborgRepos(previousConfig)
15961596

1597-
// Apps present in current config: repos to add = currentRepos
1597+
// App newly added to this suborg: select all currently targeted repos
15981598
for (const slug of currentAppSlugs) {
1599+
if (previousAppSlugs.has(slug)) continue
15991600
const entry = ensureEntry(slug)
16001601
if (!entry) continue
16011602
for (const repo of currentRepos) {
16021603
entry.repository_selection.add(repo)
16031604
}
16041605
}
16051606

1606-
// Apps removed from config: repos to unselect = previousRepos
1607+
// App removed from this suborg: unselect all previously targeted repos
16071608
for (const slug of previousAppSlugs) {
1608-
if (currentAppSlugs.has(slug)) continue // still present
1609+
if (currentAppSlugs.has(slug)) continue
16091610
const entry = ensureEntry(slug)
16101611
if (!entry) continue
16111612
for (const repo of previousRepos) {
16121613
entry.repository_unselection.add(repo)
16131614
}
16141615
}
16151616

1616-
// Apps still present but targeting changed: unselect repos no longer targeted
1617-
for (const slug of currentAppSlugs) {
1618-
if (!previousAppSlugs.has(slug)) continue // newly added, no unselection needed
1619-
const entry = ensureEntry(slug)
1620-
if (!entry) continue
1621-
for (const repo of previousRepos) {
1622-
if (!currentRepos.has(repo)) {
1623-
entry.repository_unselection.add(repo)
1624-
}
1617+
// App present in both: only act on the targeting diff. If the targeting
1618+
// is unchanged, skip entirely to avoid redundant churn.
1619+
const addedRepos = [...currentRepos].filter(r => !previousRepos.has(r))
1620+
const removedRepos = [...previousRepos].filter(r => !currentRepos.has(r))
1621+
if (addedRepos.length > 0 || removedRepos.length > 0) {
1622+
for (const slug of currentAppSlugs) {
1623+
if (!previousAppSlugs.has(slug)) continue // handled as "newly added" above
1624+
const entry = ensureEntry(slug)
1625+
if (!entry) continue
1626+
for (const repo of addedRepos) entry.repository_selection.add(repo)
1627+
for (const repo of removedRepos) entry.repository_unselection.add(repo)
16251628
}
16261629
}
16271630
}
@@ -1646,14 +1649,17 @@ class Settings {
16461649
}
16471650
const previousAppSlugs = new Set(previousApps.map(a => a.app_slug).filter(Boolean))
16481651

1649-
// Apps present in current config: add this repo
1652+
// App newly added to this repo config: select this repo. If the app was
1653+
// already present in the previous version, its selection is unchanged —
1654+
// skip to avoid redundant churn.
16501655
for (const slug of currentAppSlugs) {
1656+
if (previousAppSlugs.has(slug)) continue
16511657
const entry = ensureEntry(slug)
16521658
if (!entry) continue
16531659
entry.repository_selection.add(repo.repo)
16541660
}
16551661

1656-
// Apps removed from config: unselect this repo
1662+
// App removed from this repo config: unselect this repo
16571663
for (const slug of previousAppSlugs) {
16581664
if (currentAppSlugs.has(slug)) continue
16591665
const entry = ensureEntry(slug)

test/unit/lib/settings.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,4 +1615,106 @@ repository:
16151615
expect(result).toEqual({ repos: [], previousPluginSections: [] })
16161616
})
16171617
})
1618+
1619+
describe('_buildAppChangesFromDelta', () => {
1620+
let settings
1621+
const AppOctokitClient = require('../../../lib/appOctokitClient')
1622+
const RepoSelector = require('../../../lib/repoSelector')
1623+
1624+
beforeEach(() => {
1625+
stubConfig = { restrictedRepos: {} }
1626+
settings = createSettings(stubConfig)
1627+
// Map app slug -> installation id
1628+
jest.spyOn(AppOctokitClient.prototype, 'listOrgInstallations').mockResolvedValue([
1629+
{ app_slug: 'my-app', id: 42 }
1630+
])
1631+
})
1632+
1633+
afterEach(() => {
1634+
jest.restoreAllMocks()
1635+
})
1636+
1637+
it('skips an app when suborg targeting and app_installations are unchanged', async () => {
1638+
// Same targeting resolves to the same repos in both versions
1639+
jest.spyOn(RepoSelector.prototype, 'resolve').mockResolvedValue(new Set(['repo-a', 'repo-b']))
1640+
1641+
// Current suborg config: app present
1642+
settings.subOrgConfigs = {
1643+
frontend: {
1644+
suborgrepos: ['repo-a', 'repo-b'],
1645+
app_installations: [{ app_slug: 'my-app' }]
1646+
}
1647+
}
1648+
// Previous version (baseRef): identical app_installations
1649+
settings.loadYamlFromRef = jest.fn().mockResolvedValue({
1650+
suborgrepos: ['repo-a', 'repo-b'],
1651+
app_installations: [{ app_slug: 'my-app' }]
1652+
})
1653+
1654+
const result = await settings._buildAppChangesFromDelta(
1655+
settings.github,
1656+
'my-enterprise',
1657+
[{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
1658+
[],
1659+
'prev-sha'
1660+
)
1661+
1662+
// No churn: nothing to add or remove
1663+
expect(result).toEqual([])
1664+
})
1665+
1666+
it('emits only the targeting diff when suborg repos change', async () => {
1667+
// previous: repo-a, repo-b ; current: repo-b, repo-c
1668+
jest.spyOn(RepoSelector.prototype, 'resolve')
1669+
.mockResolvedValueOnce(new Set(['repo-b', 'repo-c'])) // current
1670+
.mockResolvedValueOnce(new Set(['repo-a', 'repo-b'])) // previous
1671+
1672+
settings.subOrgConfigs = {
1673+
frontend: {
1674+
suborgrepos: ['repo-b', 'repo-c'],
1675+
app_installations: [{ app_slug: 'my-app' }]
1676+
}
1677+
}
1678+
settings.loadYamlFromRef = jest.fn().mockResolvedValue({
1679+
suborgrepos: ['repo-a', 'repo-b'],
1680+
app_installations: [{ app_slug: 'my-app' }]
1681+
})
1682+
1683+
const result = await settings._buildAppChangesFromDelta(
1684+
settings.github,
1685+
'my-enterprise',
1686+
[{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
1687+
[],
1688+
'prev-sha'
1689+
)
1690+
1691+
expect(result).toHaveLength(1)
1692+
expect(result[0].app_slug).toBe('my-app')
1693+
expect(result[0].repository_selection.sort()).toEqual(['repo-c'])
1694+
expect(result[0].repository_unselection.sort()).toEqual(['repo-a'])
1695+
})
1696+
1697+
it('skips apps configured as repository_selection: all at org level', async () => {
1698+
jest.spyOn(RepoSelector.prototype, 'resolve').mockResolvedValue(new Set(['repo-a']))
1699+
1700+
settings.config = {
1701+
...settings.config,
1702+
app_installations: [{ app_slug: 'my-app', repository_selection: 'all' }]
1703+
}
1704+
settings.subOrgConfigs = {
1705+
frontend: { suborgrepos: ['repo-a'], app_installations: [{ app_slug: 'my-app' }] }
1706+
}
1707+
settings.loadYamlFromRef = jest.fn().mockResolvedValue({})
1708+
1709+
const result = await settings._buildAppChangesFromDelta(
1710+
settings.github,
1711+
'my-enterprise',
1712+
[{ repo: 'frontend', path: '.github/suborgs/frontend.yml' }],
1713+
[],
1714+
'prev-sha'
1715+
)
1716+
1717+
expect(result).toEqual([])
1718+
})
1719+
})
16181720
}) // Settings Tests

0 commit comments

Comments
 (0)