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

chrisns / repomanager / 26577221291

28 May 2026 01:22PM UTC coverage: 85.376% (+63.7%) from 21.649%
26577221291

push

github

GitHub
Update templated files

376 of 489 branches covered (76.89%)

Branch coverage included in aggregate %.

634 of 694 relevant lines covered (91.35%)

14.19 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!
32
    if (errors) {
×
33
      console.info(`[read-only] ${slug}: invalid repo-config.yml — would open invalid-config issue`)
×
34
      return { skipped: true, reason: 'read-only-invalid' }
×
35
    }
36
    try {
×
37
      const changes = await planRepo(octokit, repo, config)
×
38
      if (!changes.length) {
×
39
        console.info(`[read-only] ${slug}: no drift`)
×
40
      } else {
41
        const ids = changes.map((c) => c.id).join(', ')
×
42
        console.info(`[read-only] ${slug}: ${changes.length} change(s) planned — ${ids}`)
×
43
      }
44
    } catch (error) {
45
      console.error(`[read-only] ${slug}: planRepo failed: ${error.message}`)
×
46
    }
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) {
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!
184
    console.info(
×
185
      `[read-only] ${slug}#${issue.number}: would apply ${[...targetIds].join(', ')}`,
186
    )
187
    return { readOnly: true }
×
188
  }
189

190
  const { config, errors } = await getRepoConfig(repo.name, repo.owner.login, octokit)
4✔
191
  if (errors) {
4!
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 · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc