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

chrisns / repomanager / 24274401663

11 Apr 2026 04:13AM UTC coverage: 84.601% (+63.0%) from 21.649%
24274401663

push

github

web-flow
Modernize stack + checkbox consent workflow (#1555)

* Modernize stack and add checkbox consent workflow

Fix silent branch-protection bugs, migrate to Repository Rulesets, introduce
a webhook-driven task-list consent issue for high-risk changes, and replace
Serverless v3 + Octokit v20 with SAM + the Octokit meta package on Node 22.

- handler.js: fix allSettled wrapper bug, drop private-repo gate, guard NPE
  on required_status_checks, deep-merge configs, stop reopening closed
  templated-files PRs
- src/planner.js + src/applier.js: typed diff, per-change try/catch, dry-run
- src/consent.js: renderPlan / upsertConsentIssue / markItemsApplied / parse
  task-list checkboxes with stable HTML-comment markers
- src/config-schema.js: zod RepoConfig; invalid configs open a tracking issue
- Rulesets: new 'rulesets' key using repos.createRepoRuleset / updateRepoRuleset
- Low-risk toggles (vuln alerts, secret scanning, etc.) apply automatically;
  high-risk (rulesets, BP, repo flags, templated files) require a ticked box
- Serverless v3 -> SAM template.yaml, Node 18 -> Node 22, arm64
- Webhook Lambda wired up for issues.edited / push / installation events
- 47 Jest tests, 91% line coverage, enforced via coverageThreshold

* Neutralise hyphens in consent-issue marker ids (CodeQL js/bad-tag-filter)

The original encodeId only rewrote literal `-->` and left the equally-valid
HTML5 comment end `--!>` (and bare `--`) untouched. CodeQL flagged this.

Percent-encode the id with encodeURIComponent and additionally swap `-` for
`%2D` so no hyphen pair can appear inside the embedded `<!-- repomanager:... -->`
comment at all. Parsers decode the id back on the way out.

Regression test fuzzes several hyphenated ids and asserts every emitted
HTML comment interior is free of `--`.

Also:
- ignore `.aws-sam/` in jest testPathIgnorePatterns and .gitignore so the
  SAM build artifact directory isn't treated as a duplicate test tree

126 of 178 branches covered (70.79%)

Branch coverage included in aggregate %.

338 of 370 new or added lines in 6 files covered. (91.35%)

341 of 374 relevant lines covered (91.18%)

7.82 hits per line

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

77.78
/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
  parseCheckedItems,
8
  markItemsApplied,
9
  openInvalidConfigIssue,
10
  closeInvalidConfigIssue,
11
  CONSENT_LABEL,
12
  ISSUE_TITLE,
13
} = require('./src/consent')
1✔
14

15
const isDryRun = () => process.env.DRY_RUN === 'true'
20✔
16

17
const shouldProcessRepo = (repo) => !repo.fork && !repo.disabled && !repo.archived
11✔
18

19
const processRepo = async (octokit, repo) => {
1✔
20
  if (!shouldProcessRepo(repo)) return { skipped: true, reason: 'filtered' }
11✔
21

22
  const { config, errors } = await getRepoConfig(repo.name, repo.owner.login, octokit)
10✔
23
  if (errors) {
10✔
24
    console.warn(`${repo.owner.login}/${repo.name}: invalid repo-config.yml`)
1✔
25
    try {
1✔
26
      await openInvalidConfigIssue(octokit, repo, errors)
1✔
27
    } catch (error) {
NEW
28
      console.error(`${repo.owner.login}/${repo.name}: failed to open invalid-config issue: ${error.message}`)
×
29
    }
30
    return { skipped: true, reason: 'invalid-config' }
1✔
31
  }
32
  try {
9✔
33
    await closeInvalidConfigIssue(octokit, repo)
9✔
34
  } catch (error) {
NEW
35
    console.warn(`${repo.owner.login}/${repo.name}: could not close invalid-config issue: ${error.message}`)
×
36
  }
37

38
  if (config.branchProtection && config.branchProtection.length) {
9✔
39
    console.warn(
1✔
40
      `${repo.owner.login}/${repo.name}: \`branchProtection\` is deprecated — prefer the \`rulesets\` key.`,
41
    )
42
  }
43

44
  const changes = await planRepo(octokit, repo, config)
9✔
45
  if (!changes.length) {
9!
NEW
46
    try {
×
NEW
47
      await upsertConsentIssue(octokit, repo, [])
×
48
    } catch {
49
      // nothing to clean up
50
    }
NEW
51
    return { applied: 0, pendingConsent: 0 }
×
52
  }
53

54
  const { autoApply, needsConsent } = splitByRisk(changes)
9✔
55
  const results = await applyChanges(octokit, repo, autoApply, { dryRun: isDryRun() })
9✔
56

57
  if (needsConsent.length) {
9!
58
    if (isDryRun()) {
9!
NEW
59
      console.info(`[dry-run] ${repo.owner.login}/${repo.name}: would upsert consent issue with ${needsConsent.length} item(s)`)
×
60
    } else {
61
      try {
9✔
62
        await upsertConsentIssue(octokit, repo, needsConsent)
9✔
63
      } catch (error) {
NEW
64
        console.error(
×
65
          `${repo.owner.login}/${repo.name}: failed to upsert consent issue: ${error.message}`,
66
        )
67
      }
68
    }
69
  } else {
NEW
70
    try {
×
NEW
71
      await upsertConsentIssue(octokit, repo, [])
×
72
    } catch {
73
      // ignore
74
    }
75
  }
76

77
  return { applied: results.length, pendingConsent: needsConsent.length }
9✔
78
}
79

80
const cron = async () => {
1✔
81
  const app = await createApp()
2✔
82
  let processed = 0
2✔
83
  let failed = 0
2✔
84
  for await (const { octokit, repository } of app.eachRepository.iterator()) {
2✔
85
    try {
3✔
86
      await processRepo(octokit, repository)
3✔
87
      processed++
3✔
88
    } catch (error) {
NEW
89
      failed++
×
NEW
90
      console.error(
×
91
        `${repository.owner.login}/${repository.name}: unexpected failure: ${error.message}`,
92
      )
93
    }
94
  }
95
  console.info(`repomanager cron complete. processed=${processed} failed=${failed}`)
2✔
96
  return { processed, failed }
2✔
97
}
98

99
const applyConsentedChanges = async (octokit, repo, issue) => {
1✔
100
  const checkedIds = parseCheckedItems(issue.body)
3✔
101
  if (!checkedIds.size) return { applied: 0 }
3✔
102

103
  const { config, errors } = await getRepoConfig(repo.name, repo.owner.login, octokit)
2✔
104
  if (errors) {
2!
NEW
105
    console.warn(`${repo.owner.login}/${repo.name}: cannot apply consent (invalid config)`)
×
NEW
106
    return { applied: 0 }
×
107
  }
108
  const changes = await planRepo(octokit, repo, config)
2✔
109
  const toApply = changes.filter((c) => checkedIds.has(c.id))
16✔
110
  if (!toApply.length) return { applied: 0 }
2!
111

112
  const results = await applyChanges(octokit, repo, toApply, { dryRun: isDryRun() })
2✔
113
  const appliedIds = new Set(results.filter((r) => r.status === 'applied').map((r) => r.change.id))
2✔
114
  if (appliedIds.size) {
2!
115
    try {
2✔
116
      await markItemsApplied(octokit, repo, issue.number, appliedIds)
2✔
117
    } catch (error) {
118
      console.error(
×
119
        `${repo.owner.login}/${repo.name}: failed to update consent issue: ${error.message}`,
120
      )
121
    }
122
  }
123
  return { applied: appliedIds.size }
2✔
124
}
125

126
const handleIssuesEdited = async (octokit, payload) => {
1✔
127
  const issue = payload.issue
1✔
128
  if (!issue) return
1!
129
  if (issue.title !== ISSUE_TITLE) return
1!
130
  const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name))
1!
131
  if (!labels.includes(CONSENT_LABEL)) return
1!
132
  await applyConsentedChanges(octokit, payload.repository, issue)
1✔
133
}
134

135
const handlePush = async (octokit, payload) => {
1✔
136
  const touched = (payload.commits || []).flatMap((c) => [
3!
137
    ...(c.added || []),
3!
138
    ...(c.modified || []),
3!
139
    ...(c.removed || []),
3!
140
  ])
141
  const relevant =
142
    payload.repository.name === '.github'
3!
143
      ? touched.includes('repo-config.yml')
144
      : touched.includes('.github/repo-config.yml')
145
  if (!relevant) return
3✔
146
  await processRepo(octokit, payload.repository)
2✔
147
}
148

149
const handleInstallation = async (octokit, payload) => {
1✔
150
  for (const repo of payload.repositories || payload.repositories_added || []) {
1!
151
    try {
2✔
152
      const fullRepo = { ...repo, owner: payload.installation.account }
2✔
153
      await processRepo(octokit, fullRepo)
2✔
154
    } catch (error) {
NEW
155
      console.error(`installation handler: ${error.message}`)
×
156
    }
157
  }
158
}
159

160
const webhook = async (event) => {
1✔
161
  const app = await createApp()
3✔
162
  app.webhooks.on('issues.edited', ({ octokit, payload }) => handleIssuesEdited(octokit, payload))
3✔
163
  app.webhooks.on('push', ({ octokit, payload }) => handlePush(octokit, payload))
3✔
164
  app.webhooks.on('installation.created', ({ octokit, payload }) => handleInstallation(octokit, payload))
3✔
165
  app.webhooks.on('installation_repositories.added', ({ octokit, payload }) =>
3✔
NEW
166
    handleInstallation(octokit, payload),
×
167
  )
168

169
  const headers = event.headers || {}
3!
170
  const signature = headers['x-hub-signature-256'] || headers['X-Hub-Signature-256']
3✔
171
  const id = headers['x-github-delivery'] || headers['X-GitHub-Delivery']
3✔
172
  const name = headers['x-github-event'] || headers['X-GitHub-Event']
3✔
173
  const body = event.body || ''
3!
174

175
  try {
3✔
176
    await app.webhooks.verifyAndReceive({ id, name, signature, payload: body })
3✔
177
    return { statusCode: 202, body: 'ok' }
2✔
178
  } catch (error) {
179
    console.error(`webhook verify/receive failed: ${error.message}`)
1✔
180
    return { statusCode: 400, body: `bad webhook: ${error.message}` }
1✔
181
  }
182
}
183

184
module.exports = {
1✔
185
  cron,
186
  webhook,
187
  processRepo,
188
  applyConsentedChanges,
189
  handleIssuesEdited,
190
  handlePush,
191
  handleInstallation,
192
}
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