@biffo/cli 0.272.7 → 0.273.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +181 -28
- package/package.json +1 -1
- package/scripts/branch-health.sh +44 -6
package/dist/index.js
CHANGED
|
@@ -8530,6 +8530,16 @@ ${lines.join("\n")}
|
|
|
8530
8530
|
writeFileSync10(pluginPyprojectPath, updated);
|
|
8531
8531
|
return toAdd;
|
|
8532
8532
|
}
|
|
8533
|
+
function applyWorkspaceSources(targetDir, cwd, relTargetDir) {
|
|
8534
|
+
const pluginPyproject = join28(targetDir, "pyproject.toml");
|
|
8535
|
+
if (!existsSync27(pluginPyproject)) return;
|
|
8536
|
+
const sourced = ensureWorkspaceSources(pluginPyproject, workspaceMemberNames(cwd));
|
|
8537
|
+
if (sourced.length > 0) {
|
|
8538
|
+
log.info(
|
|
8539
|
+
`Sourced ${sourced.join(", ")} from the workspace in ${relTargetDir}/pyproject.toml (the instance provides it as a workspace member).`
|
|
8540
|
+
);
|
|
8541
|
+
}
|
|
8542
|
+
}
|
|
8533
8543
|
|
|
8534
8544
|
// src/commands/plugin-install.ts
|
|
8535
8545
|
var TARGET_PATTERN = /^([a-z][a-z0-9-]*)@(\d+\.\d+)$/;
|
|
@@ -8699,15 +8709,7 @@ async function runPluginInstall(target, options, deps) {
|
|
|
8699
8709
|
});
|
|
8700
8710
|
log.success(`Installed plugin source at ${relTargetDir}/`);
|
|
8701
8711
|
}
|
|
8702
|
-
|
|
8703
|
-
if (existsSync28(pluginPyproject)) {
|
|
8704
|
-
const sourced = ensureWorkspaceSources(pluginPyproject, workspaceMemberNames(options.cwd));
|
|
8705
|
-
if (sourced.length > 0) {
|
|
8706
|
-
log.info(
|
|
8707
|
-
`Sourced ${sourced.join(", ")} from the workspace in ${relTargetDir}/pyproject.toml (the instance provides it as a workspace member).`
|
|
8708
|
-
);
|
|
8709
|
-
}
|
|
8710
|
-
}
|
|
8712
|
+
applyWorkspaceSources(targetDir, options.cwd, relTargetDir);
|
|
8711
8713
|
const stagePaths = [relTargetDir];
|
|
8712
8714
|
const tfSourceDir = join29(targetDir, "terraform");
|
|
8713
8715
|
if (existsSync28(tfSourceDir)) {
|
|
@@ -9074,37 +9076,63 @@ function printDryRun5(name, version, stagePaths, keepData) {
|
|
|
9074
9076
|
|
|
9075
9077
|
// src/commands/plugin-upgrade.ts
|
|
9076
9078
|
import { cpSync as cpSync4, existsSync as existsSync32, mkdirSync as mkdirSync10, readFileSync as readFileSync24, rmSync as rmSync9 } from "fs";
|
|
9077
|
-
import { join as join33, relative as relative5, resolve as resolve16 } from "path";
|
|
9079
|
+
import { basename as basename2, join as join33, relative as relative5, resolve as resolve16 } from "path";
|
|
9078
9080
|
import chalk19 from "chalk";
|
|
9079
9081
|
import { Command as Command19 } from "commander";
|
|
9080
9082
|
import inquirer7 from "inquirer";
|
|
9081
9083
|
var pluginUpgradeCommand = new Command19("upgrade").description(
|
|
9082
|
-
"Upgrade an installed plugin to a new minor version
|
|
9083
|
-
).argument(
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
9094
|
-
|
|
9095
|
-
|
|
9096
|
-
|
|
9097
|
-
|
|
9084
|
+
"Upgrade an installed plugin to a new minor version (biffo plugin upgrade <name>@<new-minor>) or refresh it in place from a local, unpublished checkout (biffo plugin upgrade --local <path>)"
|
|
9085
|
+
).argument(
|
|
9086
|
+
"[target]",
|
|
9087
|
+
"Plugin name and new minor version, e.g. rbac@1.1 (omit when using --local)"
|
|
9088
|
+
).option(
|
|
9089
|
+
"--local <path>",
|
|
9090
|
+
"Refresh the installed plugin from a local, unpublished checkout instead of the registry"
|
|
9091
|
+
).option("--dry-run", "Resolve the new version and print planned changes without applying them").option("--force", "Skip the confirmation prompt").option("--cwd <path>", "Project root to upgrade in (defaults to the current directory)").action(
|
|
9092
|
+
async (target, options) => {
|
|
9093
|
+
const cwd = options.cwd ? resolve16(options.cwd) : process.cwd();
|
|
9094
|
+
try {
|
|
9095
|
+
await runPluginUpgrade(
|
|
9096
|
+
target,
|
|
9097
|
+
{
|
|
9098
|
+
...options.local ? { local: resolve16(options.local) } : {},
|
|
9099
|
+
dryRun: options.dryRun ?? false,
|
|
9100
|
+
force: options.force ?? false,
|
|
9101
|
+
cwd
|
|
9102
|
+
},
|
|
9103
|
+
{
|
|
9104
|
+
registry: new RegistryAdapter(),
|
|
9105
|
+
git: new GitAdapter(),
|
|
9106
|
+
migrations: new PluginMigrationsAdapter()
|
|
9107
|
+
}
|
|
9108
|
+
);
|
|
9109
|
+
} catch (err) {
|
|
9110
|
+
log.error(err.message);
|
|
9111
|
+
process.exit(1);
|
|
9112
|
+
}
|
|
9098
9113
|
}
|
|
9099
|
-
|
|
9114
|
+
);
|
|
9100
9115
|
async function runPluginUpgrade(target, options, deps) {
|
|
9101
|
-
|
|
9116
|
+
if (options.local && target) {
|
|
9117
|
+
throw new Error(
|
|
9118
|
+
`Pass either a registry target (<name>@<new-minor>) or --local <path>, not both. --local refreshes an installed plugin from an unpublished checkout on disk and has no registry entry to resolve.`
|
|
9119
|
+
);
|
|
9120
|
+
}
|
|
9121
|
+
if (!options.local && !target) {
|
|
9122
|
+
throw new Error(
|
|
9123
|
+
`Nothing to upgrade. Pass a registry target (e.g. \`biffo plugin upgrade acme-crm@1.1\`) or a local checkout to refresh from (\`biffo plugin upgrade --local ../acme-crm\`).`
|
|
9124
|
+
);
|
|
9125
|
+
}
|
|
9102
9126
|
const servicesDir = join33(options.cwd, "services");
|
|
9103
9127
|
if (!existsSync32(servicesDir)) {
|
|
9104
9128
|
throw new Error(
|
|
9105
9129
|
`${servicesDir} does not exist \u2014 is ${options.cwd} the root of a Biffo project checkout?`
|
|
9106
9130
|
);
|
|
9107
9131
|
}
|
|
9132
|
+
if (options.local) {
|
|
9133
|
+
return runLocalPluginRefresh(options.local, options, deps);
|
|
9134
|
+
}
|
|
9135
|
+
const { name, minor } = parsePluginTarget(target);
|
|
9108
9136
|
const targetDir = join33(servicesDir, name);
|
|
9109
9137
|
if (!existsSync32(targetDir)) {
|
|
9110
9138
|
throw new Error(
|
|
@@ -9152,6 +9180,7 @@ async function runPluginUpgrade(target, options, deps) {
|
|
|
9152
9180
|
mkdirSync10(targetDir, { recursive: true });
|
|
9153
9181
|
cpSync4(tmpDir, targetDir, { recursive: true });
|
|
9154
9182
|
log.success(`Upgraded plugin source at services/${entry.name}/`);
|
|
9183
|
+
applyWorkspaceSources(targetDir, options.cwd, `services/${entry.name}`);
|
|
9155
9184
|
const stagePaths = [`services/${entry.name}`];
|
|
9156
9185
|
if (existsSync32(modulesDir)) {
|
|
9157
9186
|
rmSync9(modulesDir, { recursive: true, force: true });
|
|
@@ -9190,6 +9219,100 @@ async function runPluginUpgrade(target, options, deps) {
|
|
|
9190
9219
|
deps.git.cleanup(tmpDir);
|
|
9191
9220
|
}
|
|
9192
9221
|
}
|
|
9222
|
+
async function runLocalPluginRefresh(localPath, options, deps) {
|
|
9223
|
+
const source = resolveLocalPlugin(localPath);
|
|
9224
|
+
log.success(`Resolved ${source.name}@${source.version} from ${source.origin}`);
|
|
9225
|
+
const servicesDir = join33(options.cwd, "services");
|
|
9226
|
+
const targetDir = join33(servicesDir, source.name);
|
|
9227
|
+
if (!existsSync32(targetDir)) {
|
|
9228
|
+
throw new Error(
|
|
9229
|
+
`Plugin '${source.name}' is not installed at services/${source.name}/. Use 'biffo plugin install --local ${localPath}' instead.`
|
|
9230
|
+
);
|
|
9231
|
+
}
|
|
9232
|
+
const inTreeSource = resolve16(source.sourceDir) === resolve16(targetDir);
|
|
9233
|
+
const currentVersion = readInstalledVersion2(targetDir);
|
|
9234
|
+
const modulesDir = join33(options.cwd, "modules", "plugins", source.name);
|
|
9235
|
+
if (options.dryRun) {
|
|
9236
|
+
printLocalDryRun(source, currentVersion, inTreeSource);
|
|
9237
|
+
return;
|
|
9238
|
+
}
|
|
9239
|
+
if (!options.force) {
|
|
9240
|
+
const proceed = await confirmRefresh(source.name, source.origin);
|
|
9241
|
+
if (!proceed) {
|
|
9242
|
+
log.warn("Refresh cancelled");
|
|
9243
|
+
return;
|
|
9244
|
+
}
|
|
9245
|
+
}
|
|
9246
|
+
const isRepo = await deps.git.isGitRepo(options.cwd);
|
|
9247
|
+
if (!isRepo) {
|
|
9248
|
+
throw new Error(
|
|
9249
|
+
`${options.cwd} is not a git repository \u2014 biffo plugin upgrade must be run from a Biffo project checkout.`
|
|
9250
|
+
);
|
|
9251
|
+
}
|
|
9252
|
+
const { manifest } = source;
|
|
9253
|
+
try {
|
|
9254
|
+
log.success(
|
|
9255
|
+
`Manifest valid \u2014 ${manifest.tables.length} table(s), ${manifest.api_routes.length} route(s)`
|
|
9256
|
+
);
|
|
9257
|
+
if (inTreeSource) {
|
|
9258
|
+
log.info(
|
|
9259
|
+
`services/${source.name}/ is already the local checkout \u2014 nothing to copy; re-syncing its Terraform module and checking for a migration.`
|
|
9260
|
+
);
|
|
9261
|
+
} else {
|
|
9262
|
+
rmSync9(targetDir, { recursive: true, force: true });
|
|
9263
|
+
mkdirSync10(targetDir, { recursive: true });
|
|
9264
|
+
cpSync4(source.sourceDir, targetDir, {
|
|
9265
|
+
recursive: true,
|
|
9266
|
+
filter: (src) => !LOCAL_COPY_EXCLUDES.has(basename2(src))
|
|
9267
|
+
});
|
|
9268
|
+
log.success(`Refreshed plugin source at services/${source.name}/ from ${source.origin}`);
|
|
9269
|
+
}
|
|
9270
|
+
applyWorkspaceSources(targetDir, options.cwd, `services/${source.name}`);
|
|
9271
|
+
const stagePaths = [`services/${source.name}`];
|
|
9272
|
+
if (existsSync32(modulesDir)) {
|
|
9273
|
+
rmSync9(modulesDir, { recursive: true, force: true });
|
|
9274
|
+
}
|
|
9275
|
+
const tfSourceDir = join33(targetDir, "terraform");
|
|
9276
|
+
if (existsSync32(tfSourceDir)) {
|
|
9277
|
+
mkdirSync10(modulesDir, { recursive: true });
|
|
9278
|
+
cpSync4(tfSourceDir, modulesDir, { recursive: true });
|
|
9279
|
+
stagePaths.push(`modules/plugins/${source.name}`);
|
|
9280
|
+
log.success(`Refreshed Terraform module at modules/plugins/${source.name}/`);
|
|
9281
|
+
}
|
|
9282
|
+
if (manifest.tables.length > 0) {
|
|
9283
|
+
log.info(
|
|
9284
|
+
`Checking for a migration for services/${source.name}/'s ${manifest.tables.length} table(s)...`
|
|
9285
|
+
);
|
|
9286
|
+
const generatedPaths = await deps.migrations.generate(options.cwd, [source.name]);
|
|
9287
|
+
for (const absPath of generatedPaths) {
|
|
9288
|
+
stagePaths.push(relative5(options.cwd, absPath));
|
|
9289
|
+
}
|
|
9290
|
+
if (generatedPaths.length > 0) {
|
|
9291
|
+
log.success(`Generated migration: ${relative5(options.cwd, generatedPaths[0])}`);
|
|
9292
|
+
} else {
|
|
9293
|
+
log.info(`${source.name}'s table set is unchanged \u2014 no migration needed.`);
|
|
9294
|
+
}
|
|
9295
|
+
} else {
|
|
9296
|
+
log.info(`${source.name} declares no tables \u2014 nothing to migrate.`);
|
|
9297
|
+
}
|
|
9298
|
+
await deps.git.add(options.cwd, stagePaths);
|
|
9299
|
+
if (!await deps.git.hasUncommittedChanges(options.cwd)) {
|
|
9300
|
+
log.warn(`services/${source.name}/ already matches ${source.origin} \u2014 nothing to commit.`);
|
|
9301
|
+
return;
|
|
9302
|
+
}
|
|
9303
|
+
const commitMessage = `chore(plugins): refresh ${source.name} from local checkout`;
|
|
9304
|
+
await deps.git.commit(options.cwd, commitMessage);
|
|
9305
|
+
log.success(`Committed: ${commitMessage}`);
|
|
9306
|
+
console.log(chalk19.bold("\n Plugin refreshed!\n"));
|
|
9307
|
+
console.log(` services/${source.name}/ now matches ${source.origin}`);
|
|
9308
|
+
console.log(" Push and redeploy to apply any updated tables and routes:");
|
|
9309
|
+
console.log(chalk19.dim(` git push`));
|
|
9310
|
+
console.log(chalk19.dim(` biffo deploy <environment> --app-only
|
|
9311
|
+
`));
|
|
9312
|
+
} finally {
|
|
9313
|
+
source.cleanup();
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9193
9316
|
function readInstalledVersion2(targetDir) {
|
|
9194
9317
|
const manifestPath = join33(targetDir, "biffo.plugin.json");
|
|
9195
9318
|
if (!existsSync32(manifestPath)) return void 0;
|
|
@@ -9206,6 +9329,17 @@ async function confirmUpgrade(name, currentVersion, newVersion) {
|
|
|
9206
9329
|
]);
|
|
9207
9330
|
return confirmed;
|
|
9208
9331
|
}
|
|
9332
|
+
async function confirmRefresh(name, origin) {
|
|
9333
|
+
const { confirmed } = await inquirer7.prompt([
|
|
9334
|
+
{
|
|
9335
|
+
type: "confirm",
|
|
9336
|
+
name: "confirmed",
|
|
9337
|
+
message: `Refresh ${chalk19.bold(name)} from ${origin}, replacing services/${name}/?`,
|
|
9338
|
+
default: false
|
|
9339
|
+
}
|
|
9340
|
+
]);
|
|
9341
|
+
return confirmed;
|
|
9342
|
+
}
|
|
9209
9343
|
function printDryRun6(entry, currentVersion) {
|
|
9210
9344
|
console.log(chalk19.bold("\n Dry run \u2014 no changes will be made\n"));
|
|
9211
9345
|
console.log(` Plugin: ${entry.name}`);
|
|
@@ -9221,6 +9355,25 @@ function printDryRun6(entry, currentVersion) {
|
|
|
9221
9355
|
console.log(` Would commit: feat(plugins): upgrade ${entry.name} to ${entry.version}
|
|
9222
9356
|
`);
|
|
9223
9357
|
}
|
|
9358
|
+
function printLocalDryRun(source, currentVersion, inTreeSource) {
|
|
9359
|
+
console.log(chalk19.bold("\n Dry run \u2014 no changes will be made\n"));
|
|
9360
|
+
console.log(` Plugin: ${source.name}`);
|
|
9361
|
+
console.log(` Installed: ${currentVersion ?? "(unknown \u2014 manifest unreadable)"}`);
|
|
9362
|
+
console.log(` Local source: ${source.origin} (manifest version ${source.version})`);
|
|
9363
|
+
console.log(
|
|
9364
|
+
inTreeSource ? ` Already in tree at services/${source.name}/ \u2014 nothing to copy, only Terraform/migration re-sync` : ` Would replace: services/${source.name}/`
|
|
9365
|
+
);
|
|
9366
|
+
console.log(
|
|
9367
|
+
` Would replace Terraform module at: modules/plugins/${source.name}/ (if the checkout has one)`
|
|
9368
|
+
);
|
|
9369
|
+
if (source.manifest.tables.length > 0) {
|
|
9370
|
+
console.log(
|
|
9371
|
+
` Would check for a migration for ${source.manifest.tables.length} table(s) (only generated if the table set changed)`
|
|
9372
|
+
);
|
|
9373
|
+
}
|
|
9374
|
+
console.log(` Would commit: chore(plugins): refresh ${source.name} from local checkout
|
|
9375
|
+
`);
|
|
9376
|
+
}
|
|
9224
9377
|
|
|
9225
9378
|
// src/commands/plugin.ts
|
|
9226
9379
|
var pluginCommand = new Command20("plugin").description("Manage Biffo plugins");
|
package/package.json
CHANGED
package/scripts/branch-health.sh
CHANGED
|
@@ -145,12 +145,12 @@ label=${REPO:-$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")}
|
|
|
145
145
|
# an older run because an API changed its sort is the same class of defect as the
|
|
146
146
|
# truncated list this replaces. Ask for the newest explicitly.
|
|
147
147
|
summary=$(gh_run list --branch "$BRANCH" --limit 200 \
|
|
148
|
-
--json workflowName,status,conclusion,headSha,createdAt,url \
|
|
148
|
+
--json workflowName,status,conclusion,headSha,createdAt,url,event \
|
|
149
149
|
--jq 'group_by(.workflowName)
|
|
150
150
|
| map(max_by(.createdAt))
|
|
151
151
|
| .[]
|
|
152
152
|
| [ (if .status == "completed" then (.conclusion // "unknown") else .status end),
|
|
153
|
-
.workflowName, .headSha[0:8], .createdAt[0:16], .url ]
|
|
153
|
+
.workflowName, .headSha[0:8], .createdAt[0:16], .url, .event ]
|
|
154
154
|
| @tsv' 2>/dev/null)
|
|
155
155
|
|
|
156
156
|
if [ -z "$summary" ]; then
|
|
@@ -165,12 +165,12 @@ cancelled=""
|
|
|
165
165
|
skipped=""
|
|
166
166
|
ok=""
|
|
167
167
|
|
|
168
|
-
while IFS="$TAB" read -r state name sha when url; do
|
|
168
|
+
while IFS="$TAB" read -r state name sha when url event; do
|
|
169
169
|
[ -n "$name" ] || continue
|
|
170
170
|
case "$state" in
|
|
171
171
|
success) ok="${ok}${name}\n" ;;
|
|
172
172
|
failure | timed_out | startup_failure)
|
|
173
|
-
failed="${failed}${state}\t${name}\t${sha}\t${when}\t${url}\n"
|
|
173
|
+
failed="${failed}${state}\t${name}\t${sha}\t${when}\t${url}\t${event}\n"
|
|
174
174
|
;;
|
|
175
175
|
cancelled) cancelled="${cancelled}${name}\n" ;;
|
|
176
176
|
skipped) skipped="${skipped}${name}\n" ;;
|
|
@@ -210,7 +210,14 @@ if [ -z "$failed" ]; then
|
|
|
210
210
|
fi
|
|
211
211
|
|
|
212
212
|
echo
|
|
213
|
-
printf '%b' "$failed" | awk -F'\t' -v r="$RED" -v o="$OFF"
|
|
213
|
+
printf '%b' "$failed" | awk -F'\t' -v r="$RED" -v o="$OFF" -v y="$YELLOW" \
|
|
214
|
+
'NF{
|
|
215
|
+
line = sprintf(" %s%s%s %s at %s %s", r, $1, o, $2, $3, $5)
|
|
216
|
+
if ($6 == "workflow_run") {
|
|
217
|
+
line = line sprintf(" %s(workflow_run trigger: this sha is not reliably the commit evaluated)%s", y, o)
|
|
218
|
+
}
|
|
219
|
+
print line
|
|
220
|
+
}'
|
|
214
221
|
|
|
215
222
|
# --- Who actually broke it ----------------------------------------------------
|
|
216
223
|
#
|
|
@@ -218,11 +225,42 @@ printf '%b' "$failed" | awk -F'\t' -v r="$RED" -v o="$OFF" 'NF{printf " %s%s%s
|
|
|
218
225
|
# branch backwards from the newest failure through consecutive failures, and
|
|
219
226
|
# report the OLDEST one in that unbroken streak. That run's commit is where the
|
|
220
227
|
# breakage started, which is very often not the person now reading this.
|
|
228
|
+
#
|
|
229
|
+
# `workflow_run`-triggered workflows are excluded from that confident
|
|
230
|
+
# attribution (#1463). A `workflow_run` run's own `headSha` (what `gh run
|
|
231
|
+
# list --json headSha` reports, and what every query in this file uses) is
|
|
232
|
+
# this run's ambient checkout ref — for this template's own
|
|
233
|
+
# error-branch-coverage-gate.yml that happens to be the default branch's HEAD
|
|
234
|
+
# at trigger time, not necessarily the commit the run's logic actually
|
|
235
|
+
# evaluated. The commit actually under test only exists in the ORIGINAL
|
|
236
|
+
# webhook payload (`github.event.workflow_run.head_sha`, which a workflow can
|
|
237
|
+
# read live and post as a commit status), and the Runs API does not expose
|
|
238
|
+
# that payload after the fact for an arbitrary workflow — confirmed against
|
|
239
|
+
# this repo's own history: `gh api .../actions/runs/<id>` for a
|
|
240
|
+
# `workflow_run`-triggered run carries no such field, only the ambient
|
|
241
|
+
# `head_sha`, which repeatedly disagreed with the SHA the run's job logs
|
|
242
|
+
# showed it actually reported a status against (e.g. run 31411324650 here:
|
|
243
|
+
# reported headSha f0d697e1 — commit "fix(deploy): package plugins that
|
|
244
|
+
# declare only admin_ingress (#1466)" — while its own job log named the
|
|
245
|
+
# evaluated commit as 0c077cf0, an unrelated `feat(cli)` commit by a
|
|
246
|
+
# different author). Naming the wrong commit with the same confidence as a
|
|
247
|
+
# push-triggered failure is worse than naming none, per the tool's own
|
|
248
|
+
# "cannot tell is never a pass" posture (see file header) — so this reports
|
|
249
|
+
# "cannot attribute" instead of guessing.
|
|
221
250
|
|
|
222
251
|
echo
|
|
223
|
-
printf '%b' "$failed" | while IFS="$TAB" read -r state name sha when url; do
|
|
252
|
+
printf '%b' "$failed" | while IFS="$TAB" read -r state name sha when url event; do
|
|
224
253
|
[ -n "$name" ] || continue
|
|
225
254
|
|
|
255
|
+
if [ "$event" = "workflow_run" ]; then
|
|
256
|
+
echo " ${RED}$name${OFF} is failing (latest observed sha ${YELLOW}$sha${OFF}, $when)"
|
|
257
|
+
echo " ${DIM}This workflow is workflow_run-triggered — its headSha is this run's own"
|
|
258
|
+
echo " ambient checkout ref, not necessarily the commit it evaluated. Cannot"
|
|
259
|
+
echo " attribute which commit broke it from run metadata alone; read the commit"
|
|
260
|
+
echo " status this workflow posts (or its job log) against the real commit.${OFF}"
|
|
261
|
+
continue
|
|
262
|
+
fi
|
|
263
|
+
|
|
226
264
|
# Sort newest-first ourselves, cut the list at the most recent SUCCESS, and
|
|
227
265
|
# take the oldest failure still inside that streak. Anything before a green run
|
|
228
266
|
# is a different, already-fixed breakage and must not be blamed for this one.
|