forgedock 1.27.0 → 1.28.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/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- package/templates/bootstrap/technical-spike.md +75 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
//
|
|
5
|
+
// gen_big.mjs <repo dir>: writes the TypeScript refactor:split fixture used by scripts/symbol-move-ts.test.sh
|
|
6
|
+
// (forge#4212): src/big.ts (~700 lines: exported consts, non-exported helpers each used only by its exported
|
|
7
|
+
// function, interfaces, classes, async functions, kept code that uses moved names, a relative import), src/util.ts
|
|
8
|
+
// and a node:test suite in test/big.test.ts (one skipped test). Specifiers carry the .ts extension so Node runs
|
|
9
|
+
// the sources with type stripping.
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const root = process.argv[2];
|
|
14
|
+
if (!root) { process.stderr.write("usage: gen_big.mjs <dir>\n"); process.exit(2); }
|
|
15
|
+
const N = 20;
|
|
16
|
+
const pad = (i) => String(i).padStart(2, "0");
|
|
17
|
+
const out = [];
|
|
18
|
+
out.push("// SPDX-License-Identifier: MIT", "// big.ts: a hotspot module for the split fixture.", "",
|
|
19
|
+
'import { clamp, type Unit } from "./util.ts";', "");
|
|
20
|
+
for (let i = 0; i < N; i++) out.push(`export const LIMIT_${pad(i)} = ${100 + i};`);
|
|
21
|
+
out.push("");
|
|
22
|
+
for (let i = 0; i < N; i++) {
|
|
23
|
+
out.push(`// scale step ${i}`, `function _scale_${pad(i)}(x: number): number {`, ` const factor = ${i + 1};`, " return x * factor;", "}", "");
|
|
24
|
+
out.push(`/** compute ${i}: scaled and clamped. */`, `export function compute_${pad(i)}(x: number): number {`, ` const scaled = _scale_${pad(i)}(x);`,
|
|
25
|
+
` return clamp(scaled, 0, LIMIT_${pad(i)});`, "}", "");
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < N; i++) {
|
|
28
|
+
out.push(`export interface Item_${pad(i)} {`, " id: number;", " unit: Unit;", "}", "");
|
|
29
|
+
out.push(`export class Store_${pad(i)} {`, ` items: Item_${pad(i)}[] = [];`, ` add(item: Item_${pad(i)}): number {`, " this.items.push(item);",
|
|
30
|
+
` return compute_${pad(i)}(item.id);`, " }", "", " size(): number {", " return this.items.length;", " }", "}", "");
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < N; i++) {
|
|
33
|
+
out.push(`export async function fetch_${pad(i)}(x: number): Promise<number> {`, " await Promise.resolve();", ` return compute_${pad(i)}(x) + 1;`, "}", "");
|
|
34
|
+
}
|
|
35
|
+
out.push("// kept code that uses moved names", "export function summary(): number {", " return compute_00(1) + LIMIT_01 + new Store_02().size();", "}", "",
|
|
36
|
+
"function _shared(x: number): number {", " return x + 1;", "}", "", "export function kept_total(x: number): number {", " return _shared(x) * 2;", "}", "",
|
|
37
|
+
"export function ping(n: number): number {", " return n <= 0 ? 0 : pong(n - 1);", "}", "", "export function pong(n: number): number {", " return n <= 0 ? 1 : ping(n - 1);", "}");
|
|
38
|
+
|
|
39
|
+
const test = [
|
|
40
|
+
'import { test } from "node:test";', 'import assert from "node:assert/strict";',
|
|
41
|
+
'import { compute_03, LIMIT_05, Store_04, fetch_06, summary, kept_total, ping } from "../src/big.ts";', "",
|
|
42
|
+
'test("compute clamps", () => { assert.equal(compute_03(1000), 103); assert.equal(compute_03(2), 8); });',
|
|
43
|
+
'test("limits", () => { assert.equal(LIMIT_05, 105); });',
|
|
44
|
+
'test("store", () => { const s = new Store_04(); assert.equal(s.add({ id: 2, unit: "m" }), 10); assert.equal(s.size(), 1); });',
|
|
45
|
+
'test("fetch", async () => { assert.equal(await fetch_06(1), 8); });',
|
|
46
|
+
'test("kept", () => { assert.equal(summary(), 1 + 101 + 0); assert.equal(kept_total(1), 4); assert.equal(ping(3), 1); });',
|
|
47
|
+
'test("later", { skip: "not yet" }, () => {});', "",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
mkdirSync(path.join(root, "src"), { recursive: true });
|
|
51
|
+
mkdirSync(path.join(root, "test"), { recursive: true });
|
|
52
|
+
writeFileSync(path.join(root, "src/big.ts"), out.join("\n") + "\n");
|
|
53
|
+
writeFileSync(path.join(root, "src/util.ts"),
|
|
54
|
+
'export type Unit = "m" | "s";\n\nexport function clamp(x: number, lo: number, hi: number): number {\n return Math.min(hi, Math.max(lo, x));\n}\n');
|
|
55
|
+
writeFileSync(path.join(root, "test/big.test.ts"), test.join("\n"));
|
|
56
|
+
writeFileSync(path.join(root, "package.json"), '{ "name": "split-fixture", "private": true, "type": "module" }\n');
|
|
@@ -341,7 +341,7 @@ hr_check_stub review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-mi
|
|
|
341
341
|
hr_check_stub review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
|
|
342
342
|
hr_check_stub review-prepare "$HR_PREPARE" CODE_INDEX_SCRIPT code-index.sh
|
|
343
343
|
hr_check_stub review-pr "$HR_CLASSIFY" DEDUP_SCRIPT issue-dedup.sh
|
|
344
|
-
|
|
344
|
+
hr_check_stub issue "$ROOT/commands/issue.md" ISSUE_CREATE_SCRIPT issue-create.sh
|
|
345
345
|
stub_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
|
|
346
346
|
stub_block "$HR_CLASSIFY" PRIORITY_SCRIPT > "$T/hr_pr.sh"
|
|
347
347
|
# no bare cwd-relative helper call in either review spec
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# hotspots.sh — deterministic size and churn facts for named files (forge#4195). No LLM, no network: git + wc + jq,
|
|
6
|
+
# yq (optional) only to read forge.yaml. Run from the repo root.
|
|
7
|
+
#
|
|
8
|
+
# Usage: hotspots.sh [--base <ref>] [--] <path>...
|
|
9
|
+
# Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[]
|
|
10
|
+
# (also when forge.yaml or yq is missing).
|
|
11
|
+
# Output (stdout, one JSON object):
|
|
12
|
+
# {"enabled","file_lines","growth_lines","files":[{path,lines,commits_90d,hotspot,over,excluded,base_lines,growth}]}
|
|
13
|
+
# lines = `wc -l` of the working-tree file (0 when missing); commits_90d = `git rev-list --count --since=90.days HEAD`;
|
|
14
|
+
# hotspot = lines × commits_90d; over = lines > file_lines; excluded = linguist-generated / linguist-vendored set by
|
|
15
|
+
# .gitattributes, or a match of an exclude_glob (shell pattern, `*` crosses `/`). Without --base no blob is read and
|
|
16
|
+
# base_lines/growth are null; with --base they are the line count at git merge-base(<ref>, HEAD) (0 when absent there)
|
|
17
|
+
# and lines - base_lines. enabled:false prints files:[] and does no per-file work.
|
|
18
|
+
# Exit: 0 ok | 2 usage or bad ref. bash 3.2 portable.
|
|
19
|
+
set -uo pipefail
|
|
20
|
+
|
|
21
|
+
die() { echo "hotspots: $*" >&2; exit 2; }
|
|
22
|
+
command -v jq >/dev/null 2>&1 || die "jq is required"
|
|
23
|
+
BASE=""
|
|
24
|
+
while [ $# -gt 0 ]; do
|
|
25
|
+
case "$1" in
|
|
26
|
+
--base) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: hotspots.sh [--base <ref>] [--] <path>..."; BASE="$2"; shift 2 ;;
|
|
27
|
+
--) shift; break ;;
|
|
28
|
+
-*) die "unknown option: $1" ;;
|
|
29
|
+
*) break ;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
# ---- config: maintenance.hotspot as JSON, {} when unreadable ----
|
|
34
|
+
CFG='{}'
|
|
35
|
+
if [ -f forge.yaml ] && command -v yq >/dev/null 2>&1; then
|
|
36
|
+
c=$(yq -o=json '.maintenance.hotspot // {}' forge.yaml 2>/dev/null) || c=""
|
|
37
|
+
printf '%s' "$c" | jq -e 'type == "object"' >/dev/null 2>&1 && CFG="$c"
|
|
38
|
+
fi
|
|
39
|
+
CFG=$(printf '%s' "$CFG" | jq -c '
|
|
40
|
+
def pos($d): if type == "number" and . > 0 then floor else $d end;
|
|
41
|
+
{ enabled: (.enabled != false), file_lines: (.file_lines | pos(2000)), growth_lines: (.growth_lines | pos(150)),
|
|
42
|
+
exclude_globs: ([(.exclude_globs // [])[]? | strings]) }')
|
|
43
|
+
ENABLED=$(printf '%s' "$CFG" | jq -r '.enabled')
|
|
44
|
+
FILE_LINES=$(printf '%s' "$CFG" | jq -r '.file_lines')
|
|
45
|
+
GROWTH_LINES=$(printf '%s' "$CFG" | jq -r '.growth_lines')
|
|
46
|
+
head_json() { printf '%s' "$CFG" | jq -c --argjson files "$1" '{enabled, file_lines, growth_lines, files: $files}'; }
|
|
47
|
+
[ "$ENABLED" = true ] || { head_json '[]'; exit 0; }
|
|
48
|
+
|
|
49
|
+
MB=""
|
|
50
|
+
if [ -n "$BASE" ]; then
|
|
51
|
+
git rev-parse --verify -q "$BASE^{commit}" >/dev/null || die "bad base ref: $BASE"
|
|
52
|
+
MB=$(git merge-base "$BASE" HEAD) || die "no merge base for $BASE and HEAD"
|
|
53
|
+
fi
|
|
54
|
+
HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null && HAS_HEAD=1
|
|
55
|
+
GLOBS=$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')
|
|
56
|
+
|
|
57
|
+
T=$(mktemp -d "${TMPDIR:-/tmp}/hotspots.XXXXXX") || die "mktemp failed"
|
|
58
|
+
trap 'rm -rf "$T"' EXIT
|
|
59
|
+
: > "$T/rows"
|
|
60
|
+
# one history walk per file, 8 at a time: wall time is the slowest batch, not the sum
|
|
61
|
+
i=0
|
|
62
|
+
for p in "$@"; do
|
|
63
|
+
i=$((i+1))
|
|
64
|
+
[ "$HAS_HEAD" = 1 ] && [ -n "${p#./}" ] && { git rev-list --count --since=90.days HEAD -- "${p#./}" > "$T/c.$i" 2>/dev/null & }
|
|
65
|
+
[ $((i % 8)) -eq 0 ] && wait
|
|
66
|
+
done
|
|
67
|
+
wait
|
|
68
|
+
i=0
|
|
69
|
+
for p in "$@"; do
|
|
70
|
+
i=$((i+1))
|
|
71
|
+
p=${p#./}
|
|
72
|
+
[ -n "$p" ] || continue
|
|
73
|
+
lines=0; [ -f "$p" ] && lines=$(wc -l < "$p" | tr -d ' ')
|
|
74
|
+
commits=$(cat "$T/c.$i" 2>/dev/null); case "$commits" in ''|*[!0-9]*) commits=0 ;; esac
|
|
75
|
+
excluded=false
|
|
76
|
+
if git check-attr linguist-generated linguist-vendored -- "$p" 2>/dev/null | grep -qE ': (set|true)$'; then excluded=true; fi
|
|
77
|
+
if [ "$excluded" = false ] && [ -n "$GLOBS" ]; then
|
|
78
|
+
while IFS= read -r g; do
|
|
79
|
+
[ -n "$g" ] || continue
|
|
80
|
+
# shellcheck disable=SC2254
|
|
81
|
+
case "$p" in $g) excluded=true; break ;; esac
|
|
82
|
+
done <<EOF
|
|
83
|
+
$GLOBS
|
|
84
|
+
EOF
|
|
85
|
+
fi
|
|
86
|
+
base_lines=null; growth=null
|
|
87
|
+
if [ -n "$MB" ]; then
|
|
88
|
+
base_lines=0
|
|
89
|
+
git cat-file -e "$MB:$p" 2>/dev/null && base_lines=$(git show "$MB:$p" | wc -l | tr -d ' ')
|
|
90
|
+
growth=$((lines - base_lines))
|
|
91
|
+
fi
|
|
92
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$p" "$lines" "$commits" "$excluded" "$base_lines" "$growth" >> "$T/rows"
|
|
93
|
+
done
|
|
94
|
+
|
|
95
|
+
FILES=$(jq -R -s -c --argjson fl "$FILE_LINES" '
|
|
96
|
+
[split("\n")[] | select(length > 0) | split("\t") |
|
|
97
|
+
{ path: .[0], lines: (.[1] | tonumber), commits_90d: (.[2] | tonumber) } as $r |
|
|
98
|
+
$r + { hotspot: ($r.lines * $r.commits_90d), over: ($r.lines > $fl), excluded: (.[3] == "true"),
|
|
99
|
+
base_lines: (.[4] | fromjson), growth: (.[5] | fromjson) }]' < "$T/rows")
|
|
100
|
+
head_json "$FILES"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# hotspots.test.sh — cases for scripts/hotspots.sh (forge#4195).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hotspots.sh"
|
|
7
|
+
PASS=0; FAILN=0
|
|
8
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/hotspots-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
|
|
9
|
+
ok() { PASS=$((PASS+1)); }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
11
|
+
eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
|
|
12
|
+
q() { printf '%s' "$OUT" | jq -c "$1"; }
|
|
13
|
+
HAVE_YQ=1; yq -o=json '.' - <<<'a: 1' 2>/dev/null | grep -q '"a"' || HAVE_YQ=0
|
|
14
|
+
|
|
15
|
+
new_repo() {
|
|
16
|
+
rm -rf "$TMP/w"; mkdir -p "$TMP/w"; cd "$TMP/w" || exit 1
|
|
17
|
+
git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
|
|
18
|
+
}
|
|
19
|
+
commit() { git add -A; git commit -qm "$1"; }
|
|
20
|
+
lines() { i=0; : > "$1"; while [ $i -lt "$2" ]; do echo "l$i" >> "$1"; i=$((i+1)); done; }
|
|
21
|
+
|
|
22
|
+
# ---- 1. exact lines, commits_90d, hotspot, over; old commits outside the window are not counted ----
|
|
23
|
+
new_repo
|
|
24
|
+
lines big.py 2100; GIT_COMMITTER_DATE="2000-01-01T00:00:00Z" GIT_AUTHOR_DATE="2000-01-01T00:00:00Z" commit old
|
|
25
|
+
echo x >> big.py; commit c1
|
|
26
|
+
echo y >> big.py; lines small.py 10; commit c2
|
|
27
|
+
OUT="$(bash "$S" big.py ./small.py missing/gone.py)"; rc=$?
|
|
28
|
+
eq "rc" "$rc" 0
|
|
29
|
+
eq "defaults" "$(q '[.enabled,.file_lines,.growth_lines]')" '[true,2000,150]'
|
|
30
|
+
eq "big facts" "$(q '.files[0]')" '{"path":"big.py","lines":2102,"commits_90d":2,"hotspot":4204,"over":true,"excluded":false,"base_lines":null,"growth":null}'
|
|
31
|
+
eq "small facts (./ stripped)" "$(q '.files[1] | [.path,.lines,.commits_90d,.over]')" '["small.py",10,1,false]'
|
|
32
|
+
eq "missing file" "$(q '.files[2] | [.path,.lines,.commits_90d,.over]')" '["missing/gone.py",0,0,false]'
|
|
33
|
+
OUT="$(bash "$S")"; eq "no paths" "$(q '.files')" '[]'
|
|
34
|
+
|
|
35
|
+
# ---- 2. --base growth: merge-base side, new file counts from 0 ----
|
|
36
|
+
git checkout -q -b feat
|
|
37
|
+
lines extra 200; cat extra >> big.py; lines fresh.py 30; commit grow
|
|
38
|
+
OUT="$(bash "$S" --base main -- big.py fresh.py)"
|
|
39
|
+
eq "growth" "$(q '.files[0] | [.base_lines,.growth,.lines]')" '[2102,200,2302]'
|
|
40
|
+
eq "new file growth" "$(q '.files[1] | [.base_lines,.growth]')" '[0,30]'
|
|
41
|
+
bash "$S" --base no-such-ref big.py >/dev/null 2>&1; eq "bad ref exit" "$?" 2
|
|
42
|
+
bash "$S" --base >/dev/null 2>&1; eq "usage exit" "$?" 2
|
|
43
|
+
bash "$S" --bogus x >/dev/null 2>&1; eq "unknown option exit" "$?" 2
|
|
44
|
+
|
|
45
|
+
# ---- 3. exclusions: linguist-generated, linguist-vendored, forge.yaml globs ----
|
|
46
|
+
mkdir -p gen vendor/lib dist
|
|
47
|
+
lines gen/api.py 2500; lines vendor/lib/x.js 2500; lines dist/bundle.js 2500
|
|
48
|
+
printf 'gen/** linguist-generated\nvendor/** linguist-vendored=true\n' > .gitattributes
|
|
49
|
+
commit gen
|
|
50
|
+
OUT="$(bash "$S" gen/api.py vendor/lib/x.js dist/bundle.js big.py)"
|
|
51
|
+
eq "attr exclusions" "$(q '[.files[] | .excluded]')" '[true,true,false,false]'
|
|
52
|
+
if [ "$HAVE_YQ" = 1 ]; then
|
|
53
|
+
printf 'maintenance:\n hotspot:\n file_lines: 100\n growth_lines: 5\n exclude_globs: ["dist/*", "*.lock"]\n' > forge.yaml
|
|
54
|
+
OUT="$(bash "$S" dist/bundle.js small.py big.py)"
|
|
55
|
+
eq "config thresholds" "$(q '[.file_lines,.growth_lines]')" '[100,5]'
|
|
56
|
+
eq "glob exclusion" "$(q '[.files[] | .excluded]')" '[true,false,false]'
|
|
57
|
+
eq "over uses configured file_lines" "$(q '[.files[] | .over]')" '[true,false,true]'
|
|
58
|
+
printf 'maintenance:\n hotspot:\n enabled: false\n' > forge.yaml
|
|
59
|
+
OUT="$(bash "$S" big.py)"
|
|
60
|
+
eq "opt-out" "$(q '[.enabled,.files]')" '[false,[]]'
|
|
61
|
+
printf 'maintenance:\n hotspot:\n file_lines: nope\n' > forge.yaml
|
|
62
|
+
OUT="$(bash "$S" big.py)"; eq "bad value falls back" "$(q '.file_lines')" 2000
|
|
63
|
+
rm -f forge.yaml
|
|
64
|
+
else
|
|
65
|
+
echo "SKIP: forge.yaml cases need mikefarah yq v4"
|
|
66
|
+
fi
|
|
67
|
+
# yq missing: defaults, still exit 0
|
|
68
|
+
mkdir -p "$TMP/noyq"; printf '#!/bin/sh\nexit 1\n' > "$TMP/noyq/yq"; chmod +x "$TMP/noyq/yq"
|
|
69
|
+
printf 'maintenance:\n hotspot:\n file_lines: 100\n' > forge.yaml
|
|
70
|
+
OUT="$(PATH="$TMP/noyq:$PATH" bash "$S" big.py)"; eq "yq unusable: defaults" "$(q '.file_lines')" 2000
|
|
71
|
+
rm -f forge.yaml
|
|
72
|
+
|
|
73
|
+
# ---- 4. 30k-commit repo: exact counts under 1 s, no blob reads, no network ----
|
|
74
|
+
rm -rf "$TMP/big"; git init -q -b main "$TMP/big"; cd "$TMP/big" || exit 1
|
|
75
|
+
now=$(date +%s)
|
|
76
|
+
awk -v now="$now" 'BEGIN { for (i = 1; i <= 30000; i++) {
|
|
77
|
+
ts = (i > 27000) ? now - (30000 - i) * 60 : now - 200 * 86400 - (27000 - i) * 600
|
|
78
|
+
print "commit refs/heads/main"; print "mark :" i; print "committer t <t@e> " ts " +0000"; print "data 1"; print "x"
|
|
79
|
+
if (i > 1) print "from :" (i - 1)
|
|
80
|
+
f = (i % 10 == 0) ? "hot.py" : "other" (i % 50) ".txt"; c = "line " i
|
|
81
|
+
print "M 644 inline " f; print "data " length(c) + 1; print c; print "" } }' | git fast-import --quiet
|
|
82
|
+
git checkout -q main
|
|
83
|
+
mkdir -p "$TMP/bin"
|
|
84
|
+
for t in gh curl wget npm npx pnpm yarn nc ssh; do printf '#!/bin/sh\necho "%s" >> "%s/net.log"\nexit 1\n' "$t" "$TMP" > "$TMP/bin/$t"; chmod +x "$TMP/bin/$t"; done
|
|
85
|
+
best=""
|
|
86
|
+
for _ in 1 2 3; do
|
|
87
|
+
start=$(date +%s%N 2>/dev/null)
|
|
88
|
+
OUT="$(PATH="$TMP/bin:$PATH" GIT_TRACE2_EVENT="$TMP/trace.json" bash "$S" hot.py other1.txt other2.txt other3.txt)"
|
|
89
|
+
end=$(date +%s%N 2>/dev/null)
|
|
90
|
+
case "$start$end" in *N*|"") best=0; break ;; esac
|
|
91
|
+
ms=$(( (end - start) / 1000000 )); if [ -z "$best" ] || [ "$ms" -lt "$best" ]; then best=$ms; fi
|
|
92
|
+
done
|
|
93
|
+
eq "30k exact commits" "$(q '[.files[] | .commits_90d]')" '[300,60,60,60]'
|
|
94
|
+
eq "30k exact lines" "$(q '[.files[] | .lines]')" '[1,1,1,1]'
|
|
95
|
+
if [ "$best" -lt 1000 ]; then ok; else bad "30k-commit run took ${best} ms (limit 1000)"; fi
|
|
96
|
+
grep -q '"rev-list"' "$TMP/trace.json" && ok || bad "git trace not captured"
|
|
97
|
+
if grep -qE '"(cat-file|show)"' "$TMP/trace.json"; then bad "blob read without --base"; else ok; fi
|
|
98
|
+
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
99
|
+
|
|
100
|
+
echo "hotspots.test.sh: $PASS passed, $FAILN failed"
|
|
101
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# issue-create.sh — deterministic /issue create path (forge#4164): parse, integrity check, dedup, section repair,
|
|
3
|
+
# REST create with token read-back, milestone, project board. commands/issue.md calls it once.
|
|
4
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
#
|
|
7
|
+
# Usage: issue-create.sh --title T (--body B | --body-file F) [--label L]... [--milestone M] [--exclude N,N]
|
|
8
|
+
# [--dry-run] [--repo OWNER/NAME] [--config forge.yaml]
|
|
9
|
+
# issue-create.sh --args "<raw \$ARGUMENTS string>" [--repo ...] [--config ...]
|
|
10
|
+
# --args is tokenized quote-aware by xargs (never eval: $(...), backticks, ; and | in caller text are never run, #2094).
|
|
11
|
+
# Repo: --repo, else yq .project.owner/.project.repo of --config (default ${FORGE_CONFIG:-forge.yaml}).
|
|
12
|
+
#
|
|
13
|
+
# Output: exactly one stdout line per run
|
|
14
|
+
# ISSUE_CREATE_RESULT:CREATED number=N url=U exit 0
|
|
15
|
+
# ISSUE_CREATE_RESULT:DRY_RUN exit 0 (draft printed, nothing created)
|
|
16
|
+
# ISSUE_CREATE_RESULT:DEDUP number=N exit 1 (an open near-duplicate exists; nothing created)
|
|
17
|
+
# ISSUE_CREATE_RESULT:USAGE exit 2 (bad flags, body or integrity marker, dedup usage error)
|
|
18
|
+
# ISSUE_CREATE_RESULT:ERROR exit 3 (GitHub rejected the create or a read-back failed)
|
|
19
|
+
# Diagnostics go to stderr. Bash 3.2 portable: no mapfile, no assoc arrays, no ${arr[@]} under set -u.
|
|
20
|
+
set -o pipefail
|
|
21
|
+
|
|
22
|
+
usage() { echo "ERROR: $*" >&2; echo "ISSUE_CREATE_RESULT:USAGE"; exit 2; }
|
|
23
|
+
fail() { echo "ERROR: $*" >&2; echo "ISSUE_CREATE_RESULT:ERROR"; exit 3; }
|
|
24
|
+
|
|
25
|
+
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
|
+
TITLE=""; BODY=""; BODY_FILE=""; HAVE_BODY=false; MILESTONE=""; EXCLUDE=""; DRY_RUN=false
|
|
27
|
+
REPO=""; CONFIG="${FORGE_CONFIG:-forge.yaml}"; RAW_ARGS=""; HAVE_RAW=false
|
|
28
|
+
LABELS=()
|
|
29
|
+
|
|
30
|
+
# --- tokenize --args (quote-aware, never executed) ---------------------------
|
|
31
|
+
TOKENS=()
|
|
32
|
+
take_tokens() { # tokenize $1 into TOKENS via a temp file so the real xargs exit status is seen
|
|
33
|
+
local tmp tok
|
|
34
|
+
case "$1" in *[![:space:]]*) ;; *) return 0 ;; esac
|
|
35
|
+
tmp=$(mktemp "${TMPDIR:-/tmp}/issue-create-args.XXXXXX") || usage "mktemp failed"
|
|
36
|
+
if ! printf '%s' "$1" | xargs -n1 printf '%s\0' > "$tmp"; then
|
|
37
|
+
rm -f "$tmp"
|
|
38
|
+
usage "failed to tokenize the arguments (unbalanced quote, or a token over xargs's limit; use --body-file for large content)"
|
|
39
|
+
fi
|
|
40
|
+
while IFS= read -r -d '' tok; do TOKENS+=("$tok"); done < "$tmp"
|
|
41
|
+
rm -f "$tmp"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Outer argv: --args/--repo/--config, or the flags themselves.
|
|
45
|
+
while [ $# -gt 0 ]; do
|
|
46
|
+
case "$1" in
|
|
47
|
+
--args) [ $# -ge 2 ] || usage "--args requires a value"; RAW_ARGS="$2"; HAVE_RAW=true; shift 2 ;;
|
|
48
|
+
--repo) [ $# -ge 2 ] || usage "--repo requires a value"; REPO="$2"; shift 2 ;;
|
|
49
|
+
--config) [ $# -ge 2 ] || usage "--config requires a value"; CONFIG="$2"; shift 2 ;;
|
|
50
|
+
*) TOKENS+=("$1"); shift ;;
|
|
51
|
+
esac
|
|
52
|
+
done
|
|
53
|
+
[ "$HAVE_RAW" = true ] && take_tokens "$RAW_ARGS"
|
|
54
|
+
|
|
55
|
+
# Flag mode only when a token is exactly a known flag: free text that mentions "--verbose" is not a flag typo.
|
|
56
|
+
FLAG_MODE=false
|
|
57
|
+
for t in ${TOKENS[@]+"${TOKENS[@]}"}; do
|
|
58
|
+
case "$t" in --dry-run|--greenfield|--spike|--title|--body|--body-file|--label|--milestone|--exclude) FLAG_MODE=true; break ;; esac
|
|
59
|
+
done
|
|
60
|
+
[ "$FLAG_MODE" = true ] || usage "no recognised flag: free-text /issue requests are drafted interactively (commands/issue.md), not by this script"
|
|
61
|
+
|
|
62
|
+
i=0; n=${#TOKENS[@]}
|
|
63
|
+
while [ "$i" -lt "$n" ]; do
|
|
64
|
+
arg="${TOKENS[$i]}"
|
|
65
|
+
case "$arg" in
|
|
66
|
+
--dry-run) DRY_RUN=true ;;
|
|
67
|
+
--greenfield) ;; # interactive draft validation only (forge#4172); accepted, no effect on a programmatic create
|
|
68
|
+
--title|--body|--body-file|--label|--milestone|--exclude|--spike)
|
|
69
|
+
[ $((i + 1)) -lt "$n" ] || usage "$arg requires a value"
|
|
70
|
+
i=$((i + 1)); v="${TOKENS[$i]}"
|
|
71
|
+
case "$arg" in
|
|
72
|
+
--title) TITLE="$v" ;;
|
|
73
|
+
--body) BODY="$v"; HAVE_BODY=true ;;
|
|
74
|
+
--body-file) BODY_FILE="$v" ;;
|
|
75
|
+
--label) LABELS+=("$v") ;;
|
|
76
|
+
--milestone) MILESTONE="$v" ;;
|
|
77
|
+
--exclude) EXCLUDE="$v" ;;
|
|
78
|
+
--spike) ;; # see --greenfield
|
|
79
|
+
esac ;;
|
|
80
|
+
--*) usage "unrecognized flag: $arg" ;;
|
|
81
|
+
*) ;; # stray words are ignored, as in the spec's flag loop
|
|
82
|
+
esac
|
|
83
|
+
i=$((i + 1))
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
# --- body and integrity marker ------------------------------------------------
|
|
87
|
+
[ -n "$TITLE" ] || usage "programmatic mode requires --title"
|
|
88
|
+
if [ "$HAVE_BODY" = true ] && [ -n "$BODY_FILE" ]; then
|
|
89
|
+
usage "programmatic mode requires exactly one of --body or --body-file (both given)"
|
|
90
|
+
elif [ "$HAVE_BODY" != true ] && [ -z "$BODY_FILE" ]; then
|
|
91
|
+
usage "programmatic mode requires exactly one of --body or --body-file (neither given)"
|
|
92
|
+
fi
|
|
93
|
+
MARKER=""
|
|
94
|
+
if [ -n "$BODY_FILE" ]; then
|
|
95
|
+
[ -f "$BODY_FILE" ] && [ -r "$BODY_FILE" ] || usage "--body-file '$BODY_FILE' is missing or unreadable"
|
|
96
|
+
BODY=$(cat "$BODY_FILE") || usage "--body-file '$BODY_FILE' is missing or unreadable"
|
|
97
|
+
MARKERS=$(printf '%s' "$BODY" | grep -oE '<!-- FORGE:BODY-INTEGRITY:[^>]+ -->' || true)
|
|
98
|
+
count=0; [ -n "$MARKERS" ] && count=$(printf '%s\n' "$MARKERS" | wc -l | tr -d ' ')
|
|
99
|
+
[ "$count" -eq 1 ] || usage "--body-file must contain exactly one FORGE:BODY-INTEGRITY marker (found $count)"
|
|
100
|
+
MARKER="$MARKERS"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# --- repository ----------------------------------------------------------------
|
|
104
|
+
if [ -z "$REPO" ] && [ -f "$CONFIG" ] && command -v yq >/dev/null 2>&1; then
|
|
105
|
+
REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG" 2>/dev/null | tr -d '"')
|
|
106
|
+
fi
|
|
107
|
+
case "$REPO" in ?*/?*) ;; *) usage "no target repository: pass --repo OWNER/NAME or a forge.yaml with project.owner/project.repo" ;; esac
|
|
108
|
+
|
|
109
|
+
# --- dedup (helper resolved from this script's own install, never the cwd) ---
|
|
110
|
+
DEDUP_SCRIPT="$SELF_DIR/issue-dedup.sh"
|
|
111
|
+
[ -f "$DEDUP_SCRIPT" ] || fail "issue-dedup.sh not found next to issue-create.sh ($SELF_DIR)"
|
|
112
|
+
DEDUP_ARGS=("$TITLE" -R "$REPO")
|
|
113
|
+
[ -n "$EXCLUDE" ] && DEDUP_ARGS+=(--exclude "$EXCLUDE")
|
|
114
|
+
DEDUP_RESULT=$(bash "$DEDUP_SCRIPT" "${DEDUP_ARGS[@]}" 2>&1); DEDUP_EXIT=$?
|
|
115
|
+
if [ "$DEDUP_EXIT" -eq 1 ]; then
|
|
116
|
+
echo "Near-duplicate detected: $DEDUP_RESULT" >&2
|
|
117
|
+
DEDUP_NUMBER=$(printf '%s\n' "$DEDUP_RESULT" | grep -oE '#[0-9]+' | head -1 | tr -d '#')
|
|
118
|
+
[ -n "$DEDUP_NUMBER" ] || usage "dedup STOP returned no issue number"
|
|
119
|
+
echo "ISSUE_CREATE_RESULT:DEDUP number=${DEDUP_NUMBER}"
|
|
120
|
+
exit 1
|
|
121
|
+
elif [ "$DEDUP_EXIT" -ne 0 ]; then
|
|
122
|
+
usage "dedup check usage error: $DEDUP_RESULT"
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
# --- mandatory sections (repair, never block) ----------------------------------
|
|
126
|
+
section_repair() { # body -> text to append for missing sections
|
|
127
|
+
local b="$1" add=""
|
|
128
|
+
printf '%s\n' "$b" | grep -q "^## Problem" || add="$add
|
|
129
|
+
## Problem
|
|
130
|
+
|
|
131
|
+
Root cause unknown — investigation needed."
|
|
132
|
+
printf '%s\n' "$b" | grep -q "^## Affected Files" || add="$add
|
|
133
|
+
## Affected Files
|
|
134
|
+
|
|
135
|
+
Files to be identified during investigation."
|
|
136
|
+
printf '%s\n' "$b" | grep -q "^## Acceptance Criteria" || add="$add
|
|
137
|
+
## Acceptance Criteria
|
|
138
|
+
|
|
139
|
+
- [ ] Fix confirmed during investigation."
|
|
140
|
+
printf '%s' "$add"
|
|
141
|
+
}
|
|
142
|
+
APPEND=$(section_repair "$BODY")
|
|
143
|
+
if [ -n "$APPEND" ]; then
|
|
144
|
+
BODY="${BODY}${APPEND}"
|
|
145
|
+
echo "WARNING: body was missing mandatory sections; placeholders added before creation" >&2
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
if [ "$DRY_RUN" = true ]; then
|
|
149
|
+
LABEL_TEXT=""; for l in ${LABELS[@]+"${LABELS[@]}"}; do LABEL_TEXT="${LABEL_TEXT:+$LABEL_TEXT, }$l"; done
|
|
150
|
+
printf '## Issue Draft (--dry-run — not created)\n\n**Repo**: %s\n**Title**: %s\n**Labels**: %s\n**Milestone**: %s\n\n---\n\n%s\n\n---\n\nRe-run without --dry-run to create this issue.\n' \
|
|
151
|
+
"$REPO" "$TITLE" "${LABEL_TEXT:-none}" "${MILESTONE:-none}" "$BODY" >&2
|
|
152
|
+
echo "ISSUE_CREATE_RESULT:DRY_RUN"
|
|
153
|
+
exit 0
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# --- REST create + create-token read-back (never gh issue create: it can swallow a 403) ---
|
|
157
|
+
CREATE_TOKEN="forge-create-$(date -u +%Y%m%dT%H%M%SZ)-$$-$RANDOM"
|
|
158
|
+
CREATE_BODY="${BODY}
|
|
159
|
+
|
|
160
|
+
<!-- issue-create-token:${CREATE_TOKEN} -->"
|
|
161
|
+
API_LABELS=()
|
|
162
|
+
for l in ${LABELS[@]+"${LABELS[@]}"}; do API_LABELS+=(-f "labels[]=$l"); done
|
|
163
|
+
CREATE_RESPONSE=$(gh api "repos/${REPO}/issues" --method POST -f title="$TITLE" -f body="$CREATE_BODY" ${API_LABELS[@]+"${API_LABELS[@]}"}) \
|
|
164
|
+
|| fail "GitHub rejected issue creation; no issue was created."
|
|
165
|
+
NEW_NUMBER=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.number // empty' 2>/dev/null)
|
|
166
|
+
case "$NEW_NUMBER" in ''|*[!0-9]*) fail "issue creation returned no issue number." ;; esac
|
|
167
|
+
ISSUE_URL=$(printf '%s' "$CREATE_RESPONSE" | jq -r '.html_url // empty' 2>/dev/null)
|
|
168
|
+
CREATED_BODY=$(gh issue view "$NEW_NUMBER" -R "$REPO" --json body --jq '.body') || fail "could not read back issue #${NEW_NUMBER}."
|
|
169
|
+
printf '%s' "$CREATED_BODY" | grep -qF "issue-create-token:${CREATE_TOKEN}" \
|
|
170
|
+
|| fail "issue #${NEW_NUMBER} did not contain this create token; refusing to report success."
|
|
171
|
+
if [ -n "$MARKER" ] && ! printf '%s\n' "$CREATED_BODY" | grep -Fqx "$MARKER"; then
|
|
172
|
+
fail "created issue #${NEW_NUMBER} body is missing expected integrity marker $MARKER"
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
if [ -n "$MILESTONE" ]; then
|
|
176
|
+
gh issue edit "$NEW_NUMBER" -R "$REPO" --milestone "$MILESTONE" >&2 || fail "could not set milestone '$MILESTONE' on #${NEW_NUMBER}."
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# Post-create safety net: GitHub or a transform may have altered the body; extend, never replace.
|
|
180
|
+
APPEND=$(section_repair "$CREATED_BODY")
|
|
181
|
+
if [ -n "$APPEND" ]; then
|
|
182
|
+
gh issue edit "$NEW_NUMBER" -R "$REPO" --body "${CREATED_BODY}${APPEND}" >&2 || echo "WARNING: could not append missing sections to #${NEW_NUMBER}" >&2
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# Project board (best effort), from forge.yaml project_board.
|
|
186
|
+
if [ -f "$CONFIG" ] && command -v yq >/dev/null 2>&1; then
|
|
187
|
+
PROJECT_NUMBER=$(yq '.project_board.project_number // ""' "$CONFIG" 2>/dev/null | tr -d '"')
|
|
188
|
+
PROJECT_OWNER=$(yq '.project_board.owner // .project.owner' "$CONFIG" 2>/dev/null | tr -d '"')
|
|
189
|
+
if [ -n "$PROJECT_NUMBER" ] && [ "$PROJECT_NUMBER" != "null" ] && [ -n "$ISSUE_URL" ]; then
|
|
190
|
+
gh project item-add "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --url "$ISSUE_URL" >/dev/null 2>&1 || true
|
|
191
|
+
fi
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
[ -n "$ISSUE_URL" ] || ISSUE_URL="https://github.com/${REPO}/issues/${NEW_NUMBER}"
|
|
195
|
+
echo "Created: ${ISSUE_URL}" >&2
|
|
196
|
+
echo "ISSUE_CREATE_RESULT:CREATED number=${NEW_NUMBER} url=${ISSUE_URL}"
|
|
197
|
+
exit 0
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# issue-create.test.sh — cases for scripts/issue-create.sh (forge#4164). Hermetic: mock gh and yq on PATH, the real
|
|
3
|
+
# scripts/issue-dedup.sh beside the script under test, no network. Bash 3.2 compatible.
|
|
4
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
5
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/issue-create.sh"
|
|
8
|
+
PASS=0; FAILN=0
|
|
9
|
+
TMP="$(mktemp -d "${TMPDIR:-/tmp}/issue-create-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
|
|
10
|
+
ok() { PASS=$((PASS+1)); }
|
|
11
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
12
|
+
mkdir -p "$TMP/bin" "$TMP/work/scripts" "$TMP/state"
|
|
13
|
+
|
|
14
|
+
# Mock gh. Logs every call to $TMP/state/log (one line per call, args joined by |).
|
|
15
|
+
# issue list -> $MOCK_CANDIDATES (tsv "number\ttitle") when set
|
|
16
|
+
# api .../issues POST -> stores the body field, answers {"number":901,...}; MOCK_POST_FAIL=1 fails, MOCK_NO_NUMBER=1 omits it
|
|
17
|
+
# issue view --json body-> stored body; MOCK_READBACK=no-token|no-marker|missing-sections alters it
|
|
18
|
+
# issue edit / project -> logged, exit 0
|
|
19
|
+
cat > "$TMP/bin/gh" <<'MOCK'
|
|
20
|
+
#!/usr/bin/env bash
|
|
21
|
+
ST="$MOCK_STATE"
|
|
22
|
+
line=""; for a in "$@"; do line="$line|$a"; done; printf '%s\n' "$line" >> "$ST/log"
|
|
23
|
+
if [ "$1" = issue ] && [ "$2" = list ]; then
|
|
24
|
+
[ -n "${MOCK_CANDIDATES:-}" ] && cat "$MOCK_CANDIDATES"; exit 0
|
|
25
|
+
fi
|
|
26
|
+
if [ "$1" = api ]; then
|
|
27
|
+
[ "${MOCK_POST_FAIL:-}" = 1 ] && { echo "HTTP 403: secondary rate limit" >&2; exit 1; }
|
|
28
|
+
prev=""; for a in "$@"; do
|
|
29
|
+
if [ "$prev" = "-f" ]; then case "$a" in body=*) printf '%s' "${a#body=}" > "$ST/body" ;; esac; fi
|
|
30
|
+
prev="$a"
|
|
31
|
+
done
|
|
32
|
+
if [ "${MOCK_NO_NUMBER:-}" = 1 ]; then echo '{}'; else echo '{"number":901,"html_url":"https://github.com/o/r/issues/901"}'; fi
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
if [ "$1" = issue ] && [ "$2" = view ]; then
|
|
36
|
+
b=$(cat "$ST/body")
|
|
37
|
+
case "${MOCK_READBACK:-}" in
|
|
38
|
+
no-token) b=$(printf '%s\n' "$b" | grep -v 'issue-create-token:') ;;
|
|
39
|
+
no-marker) b=$(printf '%s\n' "$b" | grep -v 'FORGE:BODY-INTEGRITY') ;;
|
|
40
|
+
missing-sections) b=$(printf '%s\n' "$b" | grep -v '^## Problem') ;;
|
|
41
|
+
esac
|
|
42
|
+
printf '%s\n' "$b"; exit 0
|
|
43
|
+
fi
|
|
44
|
+
exit 0
|
|
45
|
+
MOCK
|
|
46
|
+
# Mock yq: answers the three expressions issue-create.sh asks of forge.yaml.
|
|
47
|
+
cat > "$TMP/bin/yq" <<'MOCK'
|
|
48
|
+
#!/usr/bin/env bash
|
|
49
|
+
case "$1" in
|
|
50
|
+
*project.repo*) echo "cfg-owner/cfg-repo" ;;
|
|
51
|
+
*project_number*) echo "${MOCK_PROJECT:-}" ;;
|
|
52
|
+
*project_board.owner*) echo "cfg-owner" ;;
|
|
53
|
+
esac
|
|
54
|
+
MOCK
|
|
55
|
+
chmod +x "$TMP/bin/gh" "$TMP/bin/yq"
|
|
56
|
+
export PATH="$TMP/bin:$PATH" MOCK_STATE="$TMP/state"
|
|
57
|
+
|
|
58
|
+
GOOD="$TMP/body.md"
|
|
59
|
+
printf '## Problem\n\np\n\n## Affected Files\n\n- a\n\n## Acceptance Criteria\n\n- [ ] x\n<!-- FORGE:BODY-INTEGRITY:42_review_t1 -->\n' > "$GOOD"
|
|
60
|
+
|
|
61
|
+
run() { # name want_rc args... (cwd = $TMP/work, which has no forge.yaml unless a case writes one)
|
|
62
|
+
local name="$1" want="$2"; shift 2
|
|
63
|
+
: > "$TMP/state/log"; rm -f "$TMP/state/body"
|
|
64
|
+
(cd "$TMP/work" && bash "$S" "$@") >"$TMP/out" 2>"$TMP/err"
|
|
65
|
+
local rc=$?
|
|
66
|
+
if [ "$rc" = "$want" ]; then ok; else bad "$name (rc=$rc want $want)"; sed 's/^/ err: /' "$TMP/err"; fi
|
|
67
|
+
}
|
|
68
|
+
out_has() { grep -qF -- "$2" "$TMP/out" && ok || bad "$1 (stdout lacks: $2)"; }
|
|
69
|
+
log_has() { grep -qF -- "$2" "$TMP/state/log" && ok || bad "$1 (gh log lacks: $2)"; }
|
|
70
|
+
log_lacks() { grep -qF -- "$2" "$TMP/state/log" && bad "$1 (gh log has: $2)" || ok; }
|
|
71
|
+
one_result() { [ "$(grep -c '^ISSUE_CREATE_RESULT:' "$TMP/out")" = 1 ] && ok || bad "$1 (not exactly one result line)"; }
|
|
72
|
+
|
|
73
|
+
# --- created ----------------------------------------------------------------
|
|
74
|
+
run "create exits 0" 0 --repo o/r --title "fix: billing crash" --body-file "$GOOD" --label bug --label "priority:P2"
|
|
75
|
+
out_has "CREATED line" "ISSUE_CREATE_RESULT:CREATED number=901 url=https://github.com/o/r/issues/901"
|
|
76
|
+
one_result "create prints one result line"
|
|
77
|
+
log_has "REST create, not gh issue create" "|api|repos/o/r/issues|--method|POST|"
|
|
78
|
+
log_lacks "never gh issue create" "|issue|create|"
|
|
79
|
+
log_has "labels are distinct fields" "|-f|labels[]=bug|-f|labels[]=priority:P2"
|
|
80
|
+
grep -q 'issue-create-token:forge-create-' "$TMP/state/body" && ok || bad "create token appended to the body"
|
|
81
|
+
log_lacks "no milestone edit when none given" "--milestone"
|
|
82
|
+
|
|
83
|
+
run "argv --body works" 0 --repo o/r --title "fix: a" --body "## Problem
|
|
84
|
+
x"
|
|
85
|
+
grep -q '^## Affected Files' "$TMP/state/body" && grep -q '^## Acceptance Criteria' "$TMP/state/body" && ok || bad "missing sections repaired before POST"
|
|
86
|
+
grep -q '^## Problem' "$TMP/state/body" && [ "$(grep -c '^## Problem' "$TMP/state/body")" = 1 ] && ok || bad "present section not duplicated"
|
|
87
|
+
|
|
88
|
+
run "milestone edit" 0 --repo o/r --title "fix: a" --body-file "$GOOD" --milestone "v2"
|
|
89
|
+
log_has "milestone set on the new issue" "|issue|edit|901|-R|o/r|--milestone|v2"
|
|
90
|
+
|
|
91
|
+
# --- --args: the raw $ARGUMENTS string from the skill --------------------------
|
|
92
|
+
run "--args quoted title" 0 --repo o/r --args "--title \"fix: billing page crash\" --body-file \"$GOOD\" --label bug"
|
|
93
|
+
log_has "multi-word title is one token" "|-f|title=fix: billing page crash|"
|
|
94
|
+
rm -f "$TMP/work/pwned"
|
|
95
|
+
run "--args never executes" 0 --repo o/r --args "--title 'fix: \$(touch pwned) \`touch pwned\`; touch pwned' --body-file \"$GOOD\""
|
|
96
|
+
[ -e "$TMP/work/pwned" ] && bad "command substitution in --args was executed" || ok
|
|
97
|
+
log_has "metacharacters kept literally" 'title=fix: $(touch pwned) `touch pwned`; touch pwned'
|
|
98
|
+
run "unbalanced quote is a usage error" 2 --repo o/r --args "--title \"fix: oops --body x"
|
|
99
|
+
out_has "usage result" "ISSUE_CREATE_RESULT:USAGE"
|
|
100
|
+
|
|
101
|
+
# --- repo from forge.yaml ---------------------------------------------------------
|
|
102
|
+
: > "$TMP/work/forge.yaml"
|
|
103
|
+
run "repo from forge.yaml" 0 --title "fix: a" --body-file "$GOOD"
|
|
104
|
+
log_has "config repo used" "|api|repos/cfg-owner/cfg-repo/issues|"
|
|
105
|
+
MOCK_PROJECT=7 run "project board add" 0 --title "fix: a" --body-file "$GOOD"
|
|
106
|
+
log_has "issue added to the board" "|project|item-add|7|--owner|cfg-owner|--url|https://github.com/o/r/issues/901"
|
|
107
|
+
rm -f "$TMP/work/forge.yaml"
|
|
108
|
+
run "no repo is a usage error" 2 --title "fix: a" --body-file "$GOOD"
|
|
109
|
+
|
|
110
|
+
# --- greenfield flags (forge#4172) are accepted, not unrecognized ---------------------
|
|
111
|
+
run "--greenfield/--spike accepted" 0 --repo o/r --args "--greenfield --spike docs/spike.md --title \"fix: a\" --body-file \"$GOOD\""
|
|
112
|
+
run "--spike without a value" 2 --repo o/r --title "fix: a" --body-file "$GOOD" --spike
|
|
113
|
+
|
|
114
|
+
# --- usage errors: nothing created ---------------------------------------------
|
|
115
|
+
run "--title without a value" 2 --repo o/r --body-file "$GOOD" --title
|
|
116
|
+
log_lacks "no POST on usage error" "|api|"
|
|
117
|
+
run "unrecognized flag" 2 --repo o/r --titel "fix: a" --title "fix: a" --body-file "$GOOD"
|
|
118
|
+
run "both bodies" 2 --repo o/r --title "fix: a" --body "x" --body-file "$GOOD"
|
|
119
|
+
run "neither body" 2 --repo o/r --title "fix: a"
|
|
120
|
+
run "unreadable body file" 2 --repo o/r --title "fix: a" --body-file "$TMP/nope.md"
|
|
121
|
+
log_lacks "no gh call before body checks pass" "|"
|
|
122
|
+
printf '## Problem\nno marker\n' > "$TMP/nomark.md"
|
|
123
|
+
run "body file without marker" 2 --repo o/r --title "fix: a" --body-file "$TMP/nomark.md"
|
|
124
|
+
printf '<!-- FORGE:BODY-INTEGRITY:a_b_c -->\n<!-- FORGE:BODY-INTEGRITY:d_e_f -->\n' > "$TMP/twomark.md"
|
|
125
|
+
run "body file with two markers" 2 --repo o/r --title "fix: a" --body-file "$TMP/twomark.md"
|
|
126
|
+
run "free text is not handled here" 2 --repo o/r --args "the billing page crashes when using --verbose"
|
|
127
|
+
run "no title" 2 --repo o/r --body-file "$GOOD" --label bug
|
|
128
|
+
|
|
129
|
+
# --- dedup STOP (real issue-dedup.sh) -------------------------------------------
|
|
130
|
+
printf '777\tfix: billing crash when credits reach zero\n' > "$TMP/cands.tsv"
|
|
131
|
+
MOCK_CANDIDATES="$TMP/cands.tsv" run "dedup STOP exits 1" 1 --repo o/r --title "fix: billing crash when credits reach zero" --body-file "$GOOD"
|
|
132
|
+
out_has "DEDUP line" "ISSUE_CREATE_RESULT:DEDUP number=777"
|
|
133
|
+
one_result "dedup prints one result line"
|
|
134
|
+
log_lacks "no POST after dedup STOP" "|api|"
|
|
135
|
+
MOCK_CANDIDATES="$TMP/cands.tsv" run "dedup STOP holds under --dry-run" 1 --repo o/r --dry-run --title "fix: billing crash when credits reach zero" --body-file "$GOOD"
|
|
136
|
+
MOCK_CANDIDATES="$TMP/cands.tsv" run "--exclude narrows the candidate set" 0 --repo o/r --exclude 777 --title "fix: billing crash when credits reach zero" --body-file "$GOOD"
|
|
137
|
+
out_has "excluded duplicate creates" "ISSUE_CREATE_RESULT:CREATED number=901"
|
|
138
|
+
MOCK_CANDIDATES="$TMP/cands.tsv" run "unrelated issue still dedups with --exclude" 1 --repo o/r --exclude 12 --title "fix: billing crash when credits reach zero" --body-file "$GOOD"
|
|
139
|
+
run "dedup usage error exits 2" 2 --repo o/r --title "-x" --body-file "$GOOD"
|
|
140
|
+
log_lacks "no POST after dedup usage error" "|api|"
|
|
141
|
+
|
|
142
|
+
# --- dry run ----------------------------------------------------------------------
|
|
143
|
+
run "dry run" 0 --repo o/r --dry-run --title "fix: a" --body-file "$GOOD" --milestone v2
|
|
144
|
+
out_has "dry-run result" "ISSUE_CREATE_RESULT:DRY_RUN"
|
|
145
|
+
log_lacks "dry run makes no POST" "|api|"
|
|
146
|
+
log_lacks "dry run makes no edit" "|issue|edit|"
|
|
147
|
+
grep -qF "Issue Draft (--dry-run" "$TMP/err" && ok || bad "dry run prints the draft"
|
|
148
|
+
|
|
149
|
+
# --- create failures and read-back gates -------------------------------------------
|
|
150
|
+
MOCK_POST_FAIL=1 run "GitHub rejection exits 3" 3 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
151
|
+
out_has "error result" "ISSUE_CREATE_RESULT:ERROR"
|
|
152
|
+
grep -q 'CREATED' "$TMP/out" && bad "rejected create reported CREATED" || ok
|
|
153
|
+
MOCK_NO_NUMBER=1 run "no issue number exits 3" 3 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
154
|
+
MOCK_READBACK=no-token run "read-back without create token exits 3" 3 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
155
|
+
grep -q 'CREATED' "$TMP/out" && bad "token-less read-back reported CREATED" || ok
|
|
156
|
+
MOCK_READBACK=no-marker run "read-back without integrity marker exits 3" 3 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
157
|
+
log_lacks "no milestone after failed read-back" "--milestone"
|
|
158
|
+
MOCK_READBACK=missing-sections run "post-create repair is not fatal" 0 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
159
|
+
log_has "missing section appended after create" "|issue|edit|901|-R|o/r|--body|"
|
|
160
|
+
|
|
161
|
+
# --- dedup helper comes from the script's own directory, never the cwd ---------------
|
|
162
|
+
printf '#!/usr/bin/env bash\necho "DUPLICATE: #666 planted"; exit 1\n' > "$TMP/work/scripts/issue-dedup.sh"
|
|
163
|
+
run "planted cwd dedup helper ignored" 0 --repo o/r --title "fix: a" --body-file "$GOOD"
|
|
164
|
+
|
|
165
|
+
echo "issue-create.test: pass=$PASS fail=$FAILN"
|
|
166
|
+
[ "$FAILN" -eq 0 ]
|