• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

chrisns / repomanager / 25019849355

27 Apr 2026 09:12PM UTC coverage: 85.3% (-1.6%) from 86.915%
25019849355

push

github

chrisns
Add READ_ONLY kill switch and turn it on while we debug the planner

The cron has been creating a fresh consent issue per repo per tick because
the planner unconditionally proposes `bp:main`, `repo:update` and
`files:pr` without ever reading current repo state — only `planRulesets`
diffs against actual. Each apply succeeds, the next cron sees the same
"drift", and we re-issue the same consent.

Wire a READ_ONLY env var through to handler.js and gate every write path
(cron processRepo, webhook applyConsentedChanges). When set, we still
plan and log, but skip applyChanges, upsertConsentIssue and
markItemsApplied. Default the deploy to ReadOnly=true so the live bot
stays paused until we've actually fixed the planner drift checks.

283 of 377 branches covered (75.07%)

Branch coverage included in aggregate %.

6 of 21 new or added lines in 1 file covered. (28.57%)

541 of 589 relevant lines covered (91.85%)

14.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.92
/handler.js
1
const { createApp } = require('./src/octokit')
1✔
2
const { getRepoConfig } = require('./src/config')
1✔
3
const { planRepo, splitByRisk } = require('./src/planner')
1✔
4
const { applyChanges } = require('./src/applier')
1✔
5
const {
6
  upsertConsentIssue,
7
  parseAllItems,
8
  markItemsApplied,
9
  postFailureComment,
10
  openInvalidConfigIssue,
11
  closeInvalidConfigIssue,
12
  CONSENT_LABEL,
13
  ISSUE_TITLE,
14
} = require('./src/consent')
1✔
15

16
const isDryRun = () => process.env.DRY_RUN === 'true'
28✔
17

18
// READ_ONLY is a hard kill switch: plan, log what we would have done, and
19
// stop. No GitHub writes — no consent issues opened or edited, no settings
20
// applied, no PRs created. Set on the deployed Lambda when something is
21
// going wrong and we want to keep the cron paused while we debug.
22
const isReadOnly = () => process.env.READ_ONLY === 'true'
17✔
23

24
const shouldProcessRepo = (repo) => !repo.fork && !repo.disabled && !repo.archived
14✔
25

26
const processRepo = async (octokit, repo) => {
1✔
27
  if (!shouldProcessRepo(repo)) return { skipped: true, reason: 'filtered' }
14✔
28

29
  const { config, errors } = await getRepoConfig(repo.name, repo.owner.login, octokit)
13✔
30
  const slug = `${repo.owner.login}/${repo.name}`
13✔
31
  if (isReadOnly()) {
13!
NEW
32
    if (errors) {
×
NEW
33
      console.info(`[read-only] ${slug}: invalid repo-config.yml — would open invalid-config issue`)
×
NEW
34
      return { skipped: true, reason: 'read-only-invalid' }
×
35
    }
NEW
36
    try {
×
NEW
37
      const changes = await planRepo(octokit, repo, config)
×
NEW
38
      if (!changes.length) {
×
NEW
39
        console.info(`[read-only] ${slug}: no drift`)
×
40
      } else {
NEW
41
        const ids = changes.map((c) => c.id).join(', ')
×
NEW
42
        console.info(`[read-only] ${slug}: ${changes.length} change(s) planned — ${ids}`)
×
43
      }
44
    } catch (error) {
NEW
45
      console.error(`[read-only] ${slug}: planRepo failed: ${error.message}`)
×
46
    }
NEW
47
    return { readOnly: true }
×
48
  }
49
  if (errors) {
13✔
50
    console.warn(`${slug}: invalid repo-config.yml`)
1✔
51
    try {
1✔
52
      await openInvalidConfigIssue(octokit, repo, errors)
1✔
53
    } catch (error) {
NEW
54
      console.error(`${slug}: failed to open invalid-config issue: ${error.message}`)
×
55
    }
56
    return { skipped: true, reason: 'invalid-config' }
1✔
57
  }
58
  try {
12✔
59
    await closeInvalidConfigIssue(octokit, repo)
12✔
60
  } catch (error) {
61
    console.warn(`${repo.owner.login}/${repo.name}: could not close invalid-config issue: ${error.message}`)
×
62
  }
63

64
  if (config.branchProtection && config.branchProtection.length) {
12✔
65
    console.warn(
1✔
66
      `${repo.owner.login}/${repo.name}: \`branchProtection\` is deprecated — prefer the \`rulesets\` key.`,
67
    )
68
  }
69

70
  const changes = await planRepo(octokit, repo, config)
12✔
71
  if (!changes.length) {
12!
72
    try {
×
73
      await upsertConsentIssue(octokit, repo, [])
×
74
    } catch {
75
      // nothing to clean up
76
    }
77
    return { applied: 0, pendingConsent: 0 }
×
78
  }
79

80
  const { autoApply, needsConsent } = splitByRisk(changes)
12✔
81
  const results = await applyChanges(octokit, repo, autoApply, { dryRun: isDryRun() })
12✔
82

83
  if (needsConsent.length) {
12!
84
    if (isDryRun()) {
12!
85
      console.info(`[dry-run] ${repo.owner.login}/${repo.name}: would upsert consent issue with ${needsConsent.length} item(s)`)
×
86
    } else {
87
      try {
12✔
88
        await upsertConsentIssue(octokit, repo, needsConsent)
12✔
89
      } catch (error) {
90
        console.error(
×
91
          `${repo.owner.login}/${repo.name}: failed to upsert consent issue: ${error.message}`,
92
        )
93
      }
94
    }
95
  } else {
96
    try {
×
97
      await upsertConsentIssue(octokit, repo, [])
×
98
    } catch {
99
      // ignore
100
    }
101
  }
102

103
  return { applied: results.length, pendingConsent: needsConsent.length }
12✔
104
}
105

106
const cronWorker = async (event) => {
1✔
107
  const installationId = event && event.installationId
5✔
108
  if (!installationId) throw new Error('cronWorker: installationId required in event')
5✔
109
  const app = await createApp()
4✔
110
  let processed = 0
4✔
111
  let failed = 0
4✔
112
  for await (const { octokit, repository } of app.eachRepository.iterator({ installationId })) {
4✔
113
    try {
6✔
114
      await processRepo(octokit, repository)
6✔
115
      processed++
6✔
116
    } catch (error) {
117
      failed++
×
118
      console.error(
×
119
        `${repository.owner.login}/${repository.name}: unexpected failure: ${error.message}`,
120
      )
121
    }
122
  }
123
  console.info(
4✔
124
    `repomanager worker complete. installationId=${installationId} processed=${processed} failed=${failed}`,
125
  )
126
  return { installationId, processed, failed }
4✔
127
}
128

129
const invokeWorker = async (functionName, installationId) => {
1✔
130
  const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda')
4✔
131
  const client = new LambdaClient({})
4✔
132
  await client.send(
4✔
133
    new InvokeCommand({
134
      FunctionName: functionName,
135
      InvocationType: 'Event',
136
      Payload: Buffer.from(JSON.stringify({ installationId })),
137
    }),
138
  )
139
}
140

141
const cronDispatcher = async () => {
1✔
142
  const app = await createApp()
3✔
143
  const installationIds = []
3✔
144
  for await (const { installation } of app.eachInstallation.iterator()) {
3✔
145
    installationIds.push(installation.id)
6✔
146
  }
147
  const workerName = process.env.WORKER_FUNCTION_NAME
3✔
148
  if (workerName) {
3✔
149
    await Promise.all(
2✔
150
      installationIds.map(async (installationId) => {
151
        try {
4✔
152
          await invokeWorker(workerName, installationId)
4✔
153
        } catch (error) {
154
          console.error(`dispatcher: failed to invoke worker for installation ${installationId}: ${error.message}`)
1✔
155
        }
156
      }),
157
    )
158
  } else {
159
    for (const installationId of installationIds) {
1✔
160
      try {
2✔
161
        await cronWorker({ installationId })
2✔
162
      } catch (error) {
163
        console.error(`dispatcher: inline worker for installation ${installationId} failed: ${error.message}`)
×
164
      }
165
    }
166
  }
167
  console.info(`repomanager dispatcher complete. dispatched=${installationIds.length}`)
3✔
168
  return { dispatched: installationIds.length }
3✔
169
}
170

171
const applyConsentedChanges = async (octokit, repo, issue) => {
1✔
172
  // Only apply items the user has ticked AND we haven't already applied. We
173
  // do not exclude items that still carry the ⚠️ marker — re-ticking a
174
  // failed row is the user's explicit retry signal; markItemsApplied will
175
  // clear the ⚠️ if the retry succeeds.
176
  const items = parseAllItems(issue.body)
7✔
177
  const targetIds = new Set(
7✔
178
    items.filter((i) => i.checked && !i.applied).map((i) => i.id),
6✔
179
  )
180
  if (!targetIds.size) return { applied: 0 }
7✔
181

182
  const slug = `${repo.owner.login}/${repo.name}`
4✔
183
  if (isReadOnly()) {
4!
NEW
184
    console.info(
×
185
      `[read-only] ${slug}#${issue.number}: would apply ${[...targetIds].join(', ')}`,
186
    )
NEW
187
    return { readOnly: true }
×
188
  }
189

190
  const { config, errors } = await getRepoConfig(repo.name, repo.owner.login, octokit)
4✔
191
  if (errors) {
4!
NEW
192
    console.warn(`${slug}: cannot apply consent (invalid config)`)
×
193
    return { applied: 0 }
×
194
  }
195
  const changes = await planRepo(octokit, repo, config)
4✔
196
  const toApply = changes.filter((c) => targetIds.has(c.id))
32✔
197
  if (!toApply.length) return { applied: 0 }
4!
198

199
  const results = await applyChanges(octokit, repo, toApply, { dryRun: isDryRun() })
4✔
200
  const appliedIds = new Set(results.filter((r) => r.status === 'applied').map((r) => r.change.id))
4✔
201
  const failures = results.filter((r) => r.status === 'failed')
4✔
202
  const failedIds = new Set(failures.map((r) => r.change.id))
4✔
203
  if (appliedIds.size || failedIds.size) {
4!
204
    try {
4✔
205
      await markItemsApplied(octokit, repo, issue.number, appliedIds, failedIds)
4✔
206
    } catch (error) {
207
      console.error(
×
208
        `${repo.owner.login}/${repo.name}: failed to update consent issue: ${error.message}`,
209
      )
210
    }
211
  }
212
  if (failures.length) {
4✔
213
    try {
1✔
214
      await postFailureComment(octokit, repo, issue.number, failures.map((r) => ({
1✔
215
        id: r.change.id,
216
        summary: r.change.summary,
217
        error: r.error,
218
      })))
219
    } catch (error) {
220
      console.error(
×
221
        `${repo.owner.login}/${repo.name}: failed to post error comment: ${error.message}`,
222
      )
223
    }
224
  }
225
  return { applied: appliedIds.size, failed: failures.length }
4✔
226
}
227

228
const handleIssuesEdited = async (octokit, payload) => {
1✔
229
  const issue = payload.issue
2✔
230
  if (!issue) return
2!
231
  if (issue.title !== ISSUE_TITLE) return
2!
232
  const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name))
2!
233
  if (!labels.includes(CONSENT_LABEL)) return
2!
234
  // Ignore edits we made ourselves (markItemsApplied / upsertConsentIssue).
235
  // Otherwise every body update fans out into another applyConsentedChanges
236
  // pass, re-running already-done apply calls (and tripping idempotency
237
  // failures like "PR already exists").
238
  const sender = payload.sender || {}
2✔
239
  if (sender.type === 'Bot' && /\[bot\]$/.test(sender.login || '')) return
2!
240
  await applyConsentedChanges(octokit, payload.repository, issue)
1✔
241
}
242

243
const handlePush = async (octokit, payload) => {
1✔
244
  const touched = (payload.commits || []).flatMap((c) => [
3!
245
    ...(c.added || []),
3!
246
    ...(c.modified || []),
3!
247
    ...(c.removed || []),
3!
248
  ])
249
  const relevant =
250
    payload.repository.name === '.github'
3!
251
      ? touched.includes('repo-config.yml')
252
      : touched.includes('.github/repo-config.yml')
253
  if (!relevant) return
3✔
254
  await processRepo(octokit, payload.repository)
2✔
255
}
256

257
const handleInstallation = async (octokit, payload) => {
1✔
258
  for (const repo of payload.repositories || payload.repositories_added || []) {
1!
259
    try {
2✔
260
      const fullRepo = { ...repo, owner: payload.installation.account }
2✔
261
      await processRepo(octokit, fullRepo)
2✔
262
    } catch (error) {
263
      console.error(`installation handler: ${error.message}`)
×
264
    }
265
  }
266
}
267

268
const webhook = async (event) => {
1✔
269
  const app = await createApp()
5✔
270
  app.webhooks.on('issues.edited', ({ octokit, payload }) => handleIssuesEdited(octokit, payload))
5✔
271
  app.webhooks.on('push', ({ octokit, payload }) => handlePush(octokit, payload))
5✔
272
  app.webhooks.on('installation.created', ({ octokit, payload }) => handleInstallation(octokit, payload))
5✔
273
  app.webhooks.on('installation_repositories.added', ({ octokit, payload }) =>
5✔
274
    handleInstallation(octokit, payload),
×
275
  )
276

277
  const headers = event.headers || {}
5!
278
  const signature = headers['x-hub-signature-256'] || headers['X-Hub-Signature-256']
5✔
279
  const id = headers['x-github-delivery'] || headers['X-GitHub-Delivery']
5✔
280
  const name = headers['x-github-event'] || headers['X-GitHub-Event']
5✔
281
  const body = event.isBase64Encoded
5✔
282
    ? Buffer.from(event.body || '', 'base64').toString('utf8')
1!
283
    : event.body || ''
4!
284

285
  try {
5✔
286
    await app.webhooks.verifyAndReceive({ id, name, signature, payload: body })
5✔
287
    return { statusCode: 202, body: 'ok' }
4✔
288
  } catch (error) {
289
    console.error(`webhook verify/receive failed: ${error.message}`)
1✔
290
    return { statusCode: 400, body: `bad webhook: ${error.message}` }
1✔
291
  }
292
}
293

294
module.exports = {
1✔
295
  cronDispatcher,
296
  cronWorker,
297
  webhook,
298
  processRepo,
299
  applyConsentedChanges,
300
  handleIssuesEdited,
301
  handlePush,
302
  handleInstallation,
303
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc