forgedock 1.4.0 → 1.5.1

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.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # derive-finding-milestone.sh — Single source of truth for deriving the
6
+ # milestone a review-finding issue should
7
+ # inherit from the PR that spawned it.
8
+ #
9
+ # Usage:
10
+ # derive-finding-milestone.sh <PR_NUMBER> [-R <owner/repo>]
11
+ #
12
+ # PR_NUMBER The number of the PR being reviewed (the one /review-pr or
13
+ # /review-pr-staging is creating findings against).
14
+ # -R owner/repo Optional. Defaults to the current repo (gh's own default
15
+ # resolution) when omitted.
16
+ #
17
+ # Resolution (3-tier, first match wins — do NOT add a second copy of this
18
+ # logic anywhere else; both commands/review-pr.md and
19
+ # commands/review-pr-staging.md call this script rather than hand-rolling
20
+ # their own copy, specifically so the two specs cannot independently drift
21
+ # the way they did before this fix — see forge#2447 precedent
22
+ # (scripts/severity-to-priority.sh) and forge#2443 (this script)):
23
+ #
24
+ # Tier 1 — PR's own milestone: `gh pr view <N> --json milestone`. Most
25
+ # direct signal — if the PR itself was filed under a milestone,
26
+ # that is authoritative.
27
+ # Tier 2 — Originating issue's milestone: parse the PR body for
28
+ # `Closes/Fixes/Resolves #N` (case-insensitive), then read that
29
+ # issue's milestone. Covers the common /work-on shape where the
30
+ # PR body says `Closes #1234` but the PR itself was never
31
+ # assigned a milestone directly.
32
+ # Tier 3 — Branch-slug match: if the PR's base or head branch starts with
33
+ # `milestone/`, slugify the branch name (strip the `milestone/`
34
+ # prefix) and look for a GitHub milestone whose slugified title
35
+ # matches — exact match first, substring match as a fallback.
36
+ # This is the path that fixes forge#2443's Instance B/C: a
37
+ # milestone-lane PR whose findings had no `Closes #N` and no
38
+ # milestone set directly, but whose *branch* names the
39
+ # milestone.
40
+ #
41
+ # Output: the resolved milestone TITLE on stdout, or nothing (empty stdout)
42
+ # if no tier resolves — this is a normal, common outcome (most
43
+ # findings are fast-lane, no milestone), not an error.
44
+ #
45
+ # Exit codes:
46
+ # 0 success — milestone title printed to stdout, or nothing if none found
47
+ # 1 error (missing/invalid argument, or `gh` call failed outright —
48
+ # fails loud rather than silently guessing)
49
+ #
50
+ # Example:
51
+ # MILESTONE_TITLE=$(scripts/derive-finding-milestone.sh "$PR_NUMBER")
52
+ # MILESTONE_FLAG=""
53
+ # [ -n "$MILESTONE_TITLE" ] && MILESTONE_FLAG="--milestone $MILESTONE_TITLE"
54
+ #
55
+ # <!-- Added: forge#2443 -->
56
+
57
+ set -euo pipefail
58
+
59
+ PR_NUMBER="${1:-}"
60
+ GH_REPO_ARGS=()
61
+
62
+ if [ -z "$PR_NUMBER" ]; then
63
+ echo "ERROR: Usage: derive-finding-milestone.sh <PR_NUMBER> [-R <owner/repo>]" >&2
64
+ exit 1
65
+ fi
66
+
67
+ if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
68
+ echo "ERROR: PR_NUMBER must be numeric, got: $PR_NUMBER" >&2
69
+ exit 1
70
+ fi
71
+
72
+ shift
73
+ while [ $# -gt 0 ]; do
74
+ case "$1" in
75
+ -R)
76
+ if [ $# -lt 2 ]; then
77
+ echo "ERROR: -R requires a value <owner/repo>" >&2
78
+ echo "Usage: derive-finding-milestone.sh <PR_NUMBER> [-R <owner/repo>]" >&2
79
+ exit 1
80
+ fi
81
+ if ! [[ "$2" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
82
+ echo "ERROR: -R value must be owner/repo format, got: $2" >&2
83
+ exit 1
84
+ fi
85
+ GH_REPO_ARGS=(-R "$2")
86
+ shift 2
87
+ ;;
88
+ *)
89
+ echo "ERROR: unknown argument: $1" >&2
90
+ exit 1
91
+ ;;
92
+ esac
93
+ done
94
+
95
+ # --- Tier 1: PR's own milestone -------------------------------------------
96
+ # stderr is routed to a separate temp file (not merged via 2>&1) so that a
97
+ # 0-exit gh call's stdout is never polluted with incidental stderr text
98
+ # (e.g. a gh CLI update notice) ahead of the jq -r parse below. Mirrors the
99
+ # GH_STDERR_TMP pattern already used in scripts/classify-lane.sh.
100
+ # Restricted to owner-only permissions (chmod 600) since the file transiently
101
+ # holds gh CLI stderr text; and trap-cleaned on EXIT (mirrors the idiom
102
+ # already in production at scripts/doctor-pipeline-state.sh) so a
103
+ # signal-interrupted run (SIGINT/SIGTERM while `gh pr view` is in flight)
104
+ # doesn't leak the temp file — the two explicit rm -f calls below still run
105
+ # on their normal/error paths; the trap is a no-op in those cases and only
106
+ # matters for exit paths those calls don't cover. <!-- Fixes: forge#2532, forge#2533 -->
107
+ GH_STDERR_TMP=$(mktemp)
108
+ chmod 600 "$GH_STDERR_TMP"
109
+ trap 'rm -f "$GH_STDERR_TMP"' EXIT
110
+ PR_JSON=$(gh pr view "$PR_NUMBER" "${GH_REPO_ARGS[@]+"${GH_REPO_ARGS[@]}"}" \
111
+ --json milestone,body,baseRefName,headRefName 2>"$GH_STDERR_TMP") || {
112
+ echo "ERROR: failed to fetch PR #$PR_NUMBER — check PR number and repo flag" >&2
113
+ cat "$GH_STDERR_TMP" >&2
114
+ rm -f "$GH_STDERR_TMP"
115
+ exit 1
116
+ }
117
+ rm -f "$GH_STDERR_TMP"
118
+
119
+ MILESTONE_TITLE=$(echo "$PR_JSON" | jq -r '.milestone.title // empty')
120
+ if [ -n "$MILESTONE_TITLE" ]; then
121
+ echo "$MILESTONE_TITLE"
122
+ exit 0
123
+ fi
124
+
125
+ # --- Tier 2: originating issue's milestone (Closes/Fixes/Resolves #N) -----
126
+ # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
127
+ # grep build on Windows, so use a bash regex + BASH_REMATCH instead of a
128
+ # lookbehind (same convention as scripts/code-index.sh).
129
+ # The leading (^|[^a-z]) group anchors the alternation to a word start so a
130
+ # close-verb embedded in a larger word (e.g. "encloses #1234") cannot match;
131
+ # the body is already lowercased, so [^a-z] is the correct complement class.
132
+ # The extra group shifts the issue-number capture to BASH_REMATCH[3].
133
+ PR_BODY=$(echo "$PR_JSON" | jq -r '.body // empty')
134
+ SOURCE_ISSUE=""
135
+ PR_BODY_LOWER=$(echo "$PR_BODY" | tr '[:upper:]' '[:lower:]')
136
+ if [[ "$PR_BODY_LOWER" =~ (^|[^a-z])(closes|fixes|resolves)[[:space:]]*#([0-9]+) ]]; then
137
+ SOURCE_ISSUE="${BASH_REMATCH[3]}"
138
+ fi
139
+
140
+ if [ -n "$SOURCE_ISSUE" ]; then
141
+ ISSUE_MILESTONE=$(gh issue view "$SOURCE_ISSUE" "${GH_REPO_ARGS[@]+"${GH_REPO_ARGS[@]}"}" \
142
+ --json milestone --jq '.milestone.title // empty' 2>/dev/null || echo "")
143
+ if [ -n "$ISSUE_MILESTONE" ]; then
144
+ echo "$ISSUE_MILESTONE"
145
+ exit 0
146
+ fi
147
+ fi
148
+
149
+ # --- Tier 3: branch-slug match ---------------------------------------------
150
+ BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.baseRefName // empty')
151
+ HEAD_BRANCH=$(echo "$PR_JSON" | jq -r '.headRefName // empty')
152
+
153
+ MILESTONE_BRANCH=""
154
+ if echo "$BASE_BRANCH" | grep -qE "^milestone/"; then
155
+ MILESTONE_BRANCH="$BASE_BRANCH"
156
+ elif echo "$HEAD_BRANCH" | grep -qE "^milestone/"; then
157
+ MILESTONE_BRANCH="$HEAD_BRANCH"
158
+ fi
159
+
160
+ if [ -n "$MILESTONE_BRANCH" ]; then
161
+ BRANCH_SLUG=$(echo "$MILESTONE_BRANCH" | sed 's|^milestone/||')
162
+
163
+ # Resolve owner/repo for the milestones API call: use -R's value if given,
164
+ # otherwise gh's own default-repo resolution (the `:owner/:repo` REST
165
+ # placeholder gh substitutes from the current git remote / repo context).
166
+ if [ ${#GH_REPO_ARGS[@]} -gt 0 ]; then
167
+ MILESTONES_REPO="${GH_REPO_ARGS[1]}"
168
+ else
169
+ MILESTONES_REPO=":owner/:repo"
170
+ fi
171
+ ALL_MILESTONES=$(gh api "repos/${MILESTONES_REPO}/milestones" 2>/dev/null || echo "")
172
+
173
+ if [ -n "$ALL_MILESTONES" ] && [ "$ALL_MILESTONES" != "[]" ]; then
174
+ # Single jq invocation: shared `def slugify` replaces the duplicated
175
+ # downcase/gsub slug transform that previously appeared once per tier
176
+ # (exact-match, then substring-fallback). Exact match is tried first
177
+ # (array 1), substring match second (array 2); `first` picks the winner
178
+ # from the concatenation, preserving the exact-match-wins-over-substring
179
+ # ordering and the regex semantics of `test($slug)` exactly as before.
180
+ # <!-- Fixes: forge#2670 -->
181
+ MATCH=$(echo "$ALL_MILESTONES" | jq -r --arg slug "$BRANCH_SLUG" '
182
+ def slugify: ascii_downcase | gsub("[^a-z0-9]+"; "-");
183
+ ([.[] | select((.title | slugify) == $slug)]
184
+ + [.[] | select((.title | slugify) | test($slug))])
185
+ | (first.title // empty)')
186
+ if [ -n "$MATCH" ]; then
187
+ echo "$MATCH"
188
+ exit 0
189
+ fi
190
+ fi
191
+ fi
192
+
193
+ # No tier resolved — normal outcome, empty stdout, success exit.
194
+ exit 0
@@ -119,6 +119,7 @@ GH_FLAG="-R $GH_REPO"
119
119
  # the diagnostic stderr text for the WARNING message on actual failure.
120
120
  # ---------------------------------------------------------------------------
121
121
  GH_STDERR_TMP=$(mktemp)
122
+ chmod 600 "$GH_STDERR_TMP"
122
123
  trap 'rm -f "$GH_STDERR_TMP"' EXIT
123
124
 
124
125
  # ---------------------------------------------------------------------------
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bash
2
+ # extract-affected-files.sh — Positionally-scoped affected-file extraction for
3
+ # /orchestrate Phase 3C Layer 1 (see commands/orchestrate/phase-3-dependency.md)
4
+ #
5
+ # Usage:
6
+ # extract-affected-files.sh <issue_number> -R <owner/repo>
7
+ # extract-affected-files.sh <issue_number> "-R <owner/repo>" (single pre-joined token also accepted)
8
+ #
9
+ # Output (stdout):
10
+ # Line 1: PROVENANCE=affected-files-section | body-fallback | none | error
11
+ # Line 2+: one extracted file path per line (zero lines when PROVENANCE=none or error)
12
+ #
13
+ # Exit codes:
14
+ # 0 — Extraction completed (including the zero-files/PROVENANCE=none case — that is
15
+ # a valid, expected outcome, NOT an error; callers must not treat exit 0 as proof
16
+ # that files were found).
17
+ # 2 — Usage error (missing issue number, malformed -R value).
18
+ #
19
+ # Extraction rules (forge#2436):
20
+ # 1. Primary path — the issue's FORGE:INVESTIGATOR comment (if one exists), scoped to
21
+ # ONLY its own "### Affected Files" section. Capture stops at the next markdown
22
+ # heading of any level, so paths mentioned in "### Evidence", "### Root Cause",
23
+ # "### Related Issues", etc. are never collected.
24
+ # -> PROVENANCE=affected-files-section
25
+ # 2. Fallback path — used ONLY when no FORGE:INVESTIGATOR comment exists at all.
26
+ # Scoped to a deliverables-shaped heading in the raw issue body:
27
+ # "## Affected Files", "## Deliverables", or "### Files to change". Capture stops
28
+ # at the next markdown heading of any level, so "## Context", "## Prior art",
29
+ # "## Related", "## Root Cause", etc. are never scanned.
30
+ # -> PROVENANCE=body-fallback
31
+ # 3. If neither path yields a scoped section containing a recognized file path:
32
+ # PROVENANCE=none, zero files. This is intentional — phase-3-dependency.md's
33
+ # Layer 4 conservative-serialization fallback fires when file extraction yields
34
+ # fewer than 2 paths, and a confident-but-wrong list (the pre-fix behavior of
35
+ # scraping the whole body/comment) defeated that safety net. Yielding nothing
36
+ # when there is nothing to justify is strictly safer than yielding something
37
+ # wrong (forge#2436).
38
+ # 4. PROVENANCE=error — distinct from `none` (forge#2504): emitted when a `gh`
39
+ # invocation itself fails (non-zero exit — network error, auth expiry, rate
40
+ # limit) AND no file path was recovered from any path (including a fallback
41
+ # attempted after a failed primary call). `none` means "we confirmed there is
42
+ # nothing to extract"; `error` means "we could not confirm that — a `gh` call
43
+ # failed, so this result is inconclusive, not a verified empty result." A `gh`
44
+ # failure that is followed by a *successful* fallback yielding real files still
45
+ # reports the normal `body-fallback` provenance — `error` only fires when the
46
+ # failure actually cost us data.
47
+ #
48
+ # Extension regex covers: py|tsx?|jsx?|sql|json|ya?ml|mjs|js|sh|md — the repo's
49
+ # dominant file types. mjs/js/sh/md were previously missing, which meant the
50
+ # "primary" INVESTIGATOR-comment path silently extracted nothing for most
51
+ # ForgeDock issues (a .mjs/.md-heavy repo), so the unscoped body fallback was
52
+ # the path that actually determined DAG edges in practice (forge#2436).
53
+ #
54
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
55
+ # SPDX-License-Identifier: AGPL-3.0-or-later
56
+
57
+ set -euo pipefail
58
+
59
+ EXT_REGEX='`[^`]*\.(py|tsx?|jsx?|sql|json|ya?ml|mjs|js|sh|md)`'
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # Argument parsing — mirrors scripts/issue-dedup.sh's -R / -R\ * case arms
63
+ # exactly (forge#1533, forge#1563: trailing/malformed -R values must be a
64
+ # usage error, never silently misread by callers as "extraction found
65
+ # nothing").
66
+ # --------------------------------------------------------------------------- #
67
+ NUM=""
68
+ REPO=""
69
+
70
+ while [[ $# -gt 0 ]]; do
71
+ case "$1" in
72
+ "")
73
+ # Empty-string token — e.g. a caller quoting an unset/empty $GH_FLAG.
74
+ # Treat as "no repo flag supplied", not as the issue number.
75
+ shift
76
+ ;;
77
+ -R)
78
+ shift
79
+ if [[ $# -eq 0 ]]; then
80
+ echo "Missing value for -R" >&2
81
+ exit 2
82
+ fi
83
+ REPO="$1"
84
+ shift
85
+ ;;
86
+ -R\ *)
87
+ # Single pre-joined token, e.g. "-R owner/repo" — produced when a caller
88
+ # quotes an already-composed $GH_FLAG variable ("$GH_FLAG") instead of
89
+ # passing -R and the repo as two separate argv tokens.
90
+ #
91
+ # Guard: a valid repo token always contains '/' (owner/repo format).
92
+ # If the value after "-R " has no '/', treat the whole token as
93
+ # malformed input rather than guessing (this script takes no other
94
+ # positional string argument that could plausibly start with "-R ").
95
+ _rval="${1#-R }"
96
+ if [[ "$_rval" == */* ]]; then
97
+ REPO="$_rval"
98
+ shift
99
+ else
100
+ echo "Malformed -R value: $1" >&2
101
+ exit 2
102
+ fi
103
+ ;;
104
+ -*)
105
+ echo "Unknown flag: $1" >&2
106
+ exit 2
107
+ ;;
108
+ *)
109
+ NUM="$1"
110
+ shift
111
+ ;;
112
+ esac
113
+ done
114
+
115
+ if [[ -z "$NUM" ]]; then
116
+ echo "Usage: extract-affected-files.sh <issue_number> -R <owner/repo>" >&2
117
+ exit 2
118
+ fi
119
+
120
+ # --------------------------------------------------------------------------- #
121
+ # Scoped section extraction — turn capture on at a target heading, off at the
122
+ # next markdown heading of any level. Same sentinel-based awk pattern already
123
+ # used elsewhere in this command family (see phase-3-dependency.md's sibling
124
+ # command work-on/build.md Phase 3C.5: `awk '/^### Deliverables/{p=1; next}
125
+ # /^### /{p=0} p'`), generalized here to stop at ANY heading level so a
126
+ # deeper sub-heading inside a deliverables section still closes capture
127
+ # rather than leaking into unrelated prose.
128
+ # --------------------------------------------------------------------------- #
129
+ extract_investigator_section() {
130
+ awk '
131
+ /^### Affected Files/ { p=1; next }
132
+ /^#/ { p=0 }
133
+ p { print }
134
+ ' <<< "$1"
135
+ }
136
+
137
+ extract_body_fallback_section() {
138
+ awk '
139
+ /^## Affected Files/ || /^## Deliverables/ || /^### Files to change/ { p=1; next }
140
+ /^#/ { p=0 }
141
+ p { print }
142
+ ' <<< "$1"
143
+ }
144
+
145
+ extract_paths() {
146
+ # -E (POSIX extended regex), not -P (PCRE): the pattern below needs only
147
+ # alternation and `?` grouping, both supported by -E, and -P depends on a
148
+ # UTF-8 locale being active (`grep: -P supports only unibyte and UTF-8
149
+ # locales` otherwise) — a portability trap the original inline pseudocode
150
+ # in phase-3-dependency.md carried (forge#2436) and that this script fixes
151
+ # by not needing PCRE at all.
152
+ grep -oE "$EXT_REGEX" <<< "$1" 2>/dev/null | tr -d '`' | sort -u || true
153
+ }
154
+
155
+ # --------------------------------------------------------------------------- #
156
+ # Primary path: FORGE:INVESTIGATOR comment, scoped to its own Affected Files section
157
+ # --------------------------------------------------------------------------- #
158
+ PROVENANCE="none"
159
+ FILES=""
160
+ GH_CALL_FAILED=0
161
+
162
+ # NOTE: deliberately no `| tail -1` here — `gh api --jq`'s raw-string output
163
+ # for a multi-line `.body` field embeds literal newlines, so isolating "the
164
+ # last comment" by taking the last output LINE would instead truncate to
165
+ # only the last line of the (possibly only) comment's body, breaking the
166
+ # heading-scoped awk extraction below. In practice Phase 1 of the pipeline
167
+ # deletes any partial FORGE:INVESTIGATOR comment before reposting (see
168
+ # work-on/investigate.md), so at most one such comment exists per issue at
169
+ # any time — matching the original Layer 1 pseudocode, which also consumed
170
+ # this stream directly with no last-comment isolation.
171
+ #
172
+ # forge#2504: `VAR=$(cmd 2>/dev/null || true)` discarded gh's exit code
173
+ # entirely, so a genuine `gh` failure (network error, auth expiry, rate
174
+ # limit) was indistinguishable from "gh succeeded, output legitimately
175
+ # empty." Use `if ! VAR=$(cmd); then ... fi` instead — exempt from
176
+ # `set -e` because it's an `if` condition, so no `|| true` is needed — and
177
+ # track the failure in GH_CALL_FAILED so it can be reflected in PROVENANCE
178
+ # below rather than silently defaulting to "none". Same idiom already
179
+ # established in scripts/transition-label.sh (forge#1991/PR #1996), adapted
180
+ # here because this script must always complete (exit 0) rather than abort,
181
+ # so failures are surfaced via a new PROVENANCE=error value instead of a
182
+ # non-zero exit.
183
+ if ! INVESTIGATOR_BODY=$(gh api "repos/${REPO}/issues/${NUM}/comments" \
184
+ --jq '.[] | select(.body | contains("FORGE:INVESTIGATOR")) | .body' 2>/dev/null); then
185
+ GH_CALL_FAILED=1
186
+ echo "WARNING: gh api call failed while fetching comments for issue #$NUM (repo: $REPO) — cannot confirm whether a FORGE:INVESTIGATOR comment exists; treating as inconclusive, not empty" >&2
187
+ INVESTIGATOR_BODY=""
188
+ fi
189
+
190
+ if [[ -n "$INVESTIGATOR_BODY" ]]; then
191
+ SCOPED=$(extract_investigator_section "$INVESTIGATOR_BODY")
192
+ FILES=$(extract_paths "$SCOPED")
193
+ if [[ -n "$FILES" ]]; then
194
+ PROVENANCE="affected-files-section"
195
+ fi
196
+ else
197
+ # --------------------------------------------------------------------------- #
198
+ # Fallback path: raw issue body, scoped to a deliverables-shaped heading.
199
+ # Reached both when NO FORGE:INVESTIGATOR comment exists (the original Layer 1
200
+ # contract: "For issues WITHOUT an investigation comment, fall back to parsing
201
+ # the issue body") AND when the primary gh call above failed (in which case we
202
+ # genuinely don't know if a comment exists, so attempting the fallback gives a
203
+ # chance at real data rather than giving up immediately).
204
+ # --------------------------------------------------------------------------- #
205
+ if ! ISSUE_BODY=$(gh issue view "$NUM" -R "$REPO" --json body --jq '.body' 2>/dev/null); then
206
+ GH_CALL_FAILED=1
207
+ echo "WARNING: gh issue view call failed for issue #$NUM (repo: $REPO) — cannot confirm the issue body's Affected Files section; treating as inconclusive, not empty" >&2
208
+ ISSUE_BODY=""
209
+ fi
210
+ if [[ -n "$ISSUE_BODY" ]]; then
211
+ SCOPED=$(extract_body_fallback_section "$ISSUE_BODY")
212
+ FILES=$(extract_paths "$SCOPED")
213
+ if [[ -n "$FILES" ]]; then
214
+ PROVENANCE="body-fallback"
215
+ fi
216
+ fi
217
+ fi
218
+
219
+ # forge#2504: only escalate to "error" when neither path yielded real data AND
220
+ # at least one gh call genuinely failed along the way. A failed primary call
221
+ # followed by a successful fallback that found files must still report
222
+ # body-fallback (real data recovered) — never overwrite that with "error".
223
+ if [[ "$PROVENANCE" == "none" && "$GH_CALL_FAILED" -eq 1 ]]; then
224
+ PROVENANCE="error"
225
+ fi
226
+
227
+ echo "PROVENANCE=$PROVENANCE"
228
+ if [[ -n "$FILES" ]]; then
229
+ printf '%s\n' "$FILES"
230
+ fi
231
+
232
+ exit 0