feat: make triage agent multi-forge (GitHub + GitLab) - #686
Conversation
Extract forge-specific operations from pre/post-triage scripts into sourced ops files (scripts/github/triage-ops.sh, scripts/gitlab/triage-ops.sh) with a shared forge_* function interface. The orchestrator scripts source the correct ops file based on FULLSEND_FORGE at runtime. Reorganize forge-specific files by platform: - policies/github/, policies/gitlab/ for sandbox policies - env/github/, env/gitlab/ for sandbox env files - skills/github/, skills/gitlab/ for general forge CLI skills - skills/issue-labels/github/, skills/issue-labels/gitlab/ for label skills Make agent prompt forge-neutral (ISSUE_URL, generic terminology, delegates CLI commands to forge skills). Update schema to accept both GitHub and GitLab URL patterns. Add forge sections to harness config with per-forge policy, skills, and env vars. All 95 tests pass (84 GitHub + 11 new GitLab). Signed-off-by: Greg Allen <gallen@redhat.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
🤖 Finished Review · ✅ Success · Started 1:08 AM UTC · Completed 1:26 AM UTC |
PR Summary by QodoMake triage agent multi-forge (GitHub + GitLab)
AI Description
Diagram
High-Level Assessment
Files changed (16)
|
Code Review by Qodo
1. ISSUE_URL unsanitized in ::notice::
|
| echo "ERROR: GITHUB_ISSUE_URL does not match expected pattern: ${GITHUB_ISSUE_URL}" | ||
| exit 1 | ||
| fi | ||
| echo "::notice::🔗 Triage target: ${ISSUE_URL}" |
There was a problem hiding this comment.
1. issue_url unsanitized in ::notice:: 📜 Skill insight ⛨ Security
scripts/pre-triage.sh emits a GitHub Actions workflow command with an interpolated ISSUE_URL that is not sanitized, enabling workflow-command injection via :: sequences, encoded newlines, or control characters. This violates the requirement that every interpolated value in workflow commands be sanitized individually.
Agent Prompt
## Issue description
`scripts/pre-triage.sh` prints a GitHub Actions workflow command (`::notice::...`) while interpolating `ISSUE_URL` without sanitization. This can allow workflow command injection if `ISSUE_URL` contains `::`, `%0A/%0D`, ANSI escapes, or other control characters.
## Issue Context
The repository already contains a `gha_echo` helper that sanitizes workflow command output in other scripts; `pre-triage.sh` should use the same (or equivalent) sanitization behavior.
## Fix Focus Areas
- scripts/pre-triage.sh[18-25]
- scripts/lib/post-failure-report.lib.sh[25-70]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| - host: "*.googleapis.com" | ||
| port: 443 | ||
| protocol: rest | ||
| enforcement: enforce | ||
| access: read-write |
There was a problem hiding this comment.
2. Gitlab policy lacks justification 📜 Skill insight ⛨ Security
The new policies/gitlab/triage.yaml expands sandbox/network permissions (e.g., *.googleapis.com, gitlab.com, gitlab.cee.redhat.com, allowed binaries) without an explicit linked issue/ADR justification in the policy itself. Permission expansions without explicit authorization and least-privilege justification violate the compliance requirement.
Agent Prompt
## Issue description
A new permission-declaring policy file is introduced for the GitLab triage forge, granting network access and binary allowances. The change lacks an explicit linked issue/ADR justification and least-privilege rationale alongside the permissions.
## Issue Context
Compliance requires permission expansions (policy manifests, RBAC, workflow permissions, etc.) to be least-privilege and explicitly justified with an authorizing issue/ADR.
## Fix Focus Areas
- policies/gitlab/triage.yaml[1-52]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| forge: | ||
| github: | ||
| policy: policies/github/triage.yaml | ||
| pre_script: scripts/pre-triage.sh | ||
| post_script: scripts/post-triage.sh | ||
| skills: | ||
| - skills/github | ||
| - skills/issue-labels/github | ||
| host_files: |
There was a problem hiding this comment.
3. Protected paths modified 📜 Skill insight § Compliance
This PR modifies protected governance/infrastructure paths (including agents/, harness/, policies/, scripts/, skills/, and schemas/), which must not be auto-approved and require explicit human review. A protected-path finding is mandatory when these directories are changed.
Agent Prompt
## Issue description
Protected governance/infrastructure paths are modified in this PR. Per compliance, such PRs must not be auto-approved and should have explicit authorization context.
## Issue Context
Protected paths include agent definitions, harness configs, policies, scripts, skills, and schemas—changes here impact pipeline behavior and security boundaries.
## Fix Focus Areas
- harness/triage.yaml[35-75]
- scripts/pre-triage.sh[1-34]
- scripts/post-triage.sh[1-30]
- policies/gitlab/triage.yaml[1-52]
- schemas/triage-result.schema.json[39-81]
- agents/triage.md[1-75]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| # shellcheck source=/dev/null | ||
| source "${SCRIPT_DIR}/${FULLSEND_FORGE}/triage-ops.sh" |
There was a problem hiding this comment.
4. Unvalidated forge ops sourcing 🐞 Bug ⛨ Security
pre-triage.sh and post-triage.sh source the ops script via an unvalidated FULLSEND_FORGE path, so a traversal value (e.g., "../..") could cause unintended code execution on the runner if FULLSEND_FORGE is ever influenced outside the trusted harness. This is a runner trust-boundary weakness because sourcing executes arbitrary bash from the resolved path.
Agent Prompt
## Issue description
`post-triage.sh` and `pre-triage.sh` build a `source` path from `$FULLSEND_FORGE` without validating it. Although the variable is quoted (preventing shell metacharacter injection), path traversal (e.g., `../..`) can still resolve to an unintended file.
## Issue Context
This code runs on the host/runner (not the sandbox). Today the harness sets `FULLSEND_FORGE` to fixed values, but the scripts should defensively enforce the trust boundary.
## Fix Focus Areas
- scripts/post-triage.sh[20-25]
- scripts/pre-triage.sh[16-21]
## What to change
- Add an explicit allowlist:
- `case "${FULLSEND_FORGE}" in github|gitlab) ;; *) echo "ERROR: invalid FULLSEND_FORGE"; exit 1 ;; esac`
- Optionally map forge->path without interpolation (e.g., set `OPS_FILE` in the `case` and `source "$OPS_FILE"`).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| notes=$(_gitlab_api GET "/projects/${REPO}/issues/${ISSUE_NUMBER}/notes?per_page=100&sort=asc" 2>/dev/null) || notes="[]" | ||
|
|
||
| local note_id | ||
| note_id=$(echo "${notes}" | jq -r --arg marker "${marker}" \ |
There was a problem hiding this comment.
5. Gitlab sticky comment duplicates 🐞 Bug ☼ Reliability
GitLab forge_post_sticky_comment only fetches the first 100 notes sorted ascending when searching for an existing marker, so on issues with >100 notes it can miss the marker and POST a new note instead of updating. This can repeatedly spam issues with duplicate “sticky” triage comments on re-triage.
Agent Prompt
## Issue description
`forge_post_sticky_comment` queries `notes?per_page=100&sort=asc` once and searches only that response for the marker. If the marker note isn’t in the first page, the script falls back to POSTing a new note, creating duplicates.
## Issue Context
Because results are sorted ascending, page 1 is the oldest notes. A marker note created later (or after many existing notes) may fall outside the first 100 and will never be found.
## Fix Focus Areas
- scripts/gitlab/triage-ops.sh[150-170]
## What to change
- Either:
- Fetch notes sorted by newest first (`sort=desc`) so the marker is likely on page 1, and still paginate if not found.
- Or:
- Implement pagination over notes (`page=1..N`) until the marker is found or pages are exhausted.
- Keep the update-in-place behavior by PUTing the found note ID; only POST when you’re sure no marker exists.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| forge_list_repo_labels() { | ||
| _gitlab_api GET "/projects/${REPO}/labels?per_page=100" 2>/dev/null | jq -r '.[].name' || true | ||
| } |
There was a problem hiding this comment.
6. Gitlab label discovery incomplete 🐞 Bug ≡ Correctness
forge_list_repo_labels on GitLab fetches only /labels?per_page=100 with no pagination, so projects with more than 100 labels will have missing entries. post-triage.sh then incorrectly treats some real labels as nonexistent and refuses valid label_actions.
Agent Prompt
## Issue description
On GitLab, `forge_list_repo_labels` fetches only the first page of labels (100 max). The post-triage label_actions gate uses this as the authoritative label set, so labels beyond page 1 are incorrectly rejected.
## Issue Context
GitHub uses `--paginate` for labels; GitLab needs equivalent pagination (via `page=` and response headers).
## Fix Focus Areas
- scripts/gitlab/triage-ops.sh[128-130]
- scripts/post-triage.sh[512-545]
## What to change
- Implement a pagination loop in `forge_list_repo_labels`, e.g.:
- loop `page=1..` until an empty array is returned
- concatenate names across pages
- Alternatively, query label existence by name per action (slower, but avoids full pagination).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| "pattern": "^[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+$" | ||
| }, |
There was a problem hiding this comment.
7. Repo schema mismatches github 🐞 Bug ≡ Correctness
The schema now allows prerequisites.create.repo values with 3+ path segments, but GitHub forge_create_issue passes repo directly to gh issue create --repo, which expects OWNER/REPO. This allows schema-valid outputs that deterministically fail prerequisite issue creation on GitHub if a multi-segment repo is produced.
Agent Prompt
## Issue description
`prerequisites.create[].repo` is validated by a shared schema that now permits nested paths (needed for GitLab), but the GitHub implementation expects exactly one slash for `--repo OWNER/REPO`.
## Issue Context
This is a contract mismatch between validation (schema) and execution (GitHub ops). The post-triage script calls `forge_create_issue` with `TARGET_REPO` without additional GitHub-specific validation.
## Fix Focus Areas
- schemas/triage-result.schema.json[71-82]
- scripts/github/triage-ops.sh[114-119]
- scripts/post-triage.sh[199-201]
## What to change
- Option A (recommended): keep the schema broad for cross-forge, but add GitHub-side validation in `scripts/github/triage-ops.sh::forge_create_issue`:
- reject repo values that don’t match `^[^/]+/[^/]+$` and return a clear error.
- Option B: change the schema field to `anyOf`:
- GitHub repo pattern (`owner/repo`)
- GitLab project path pattern (`group(/subgroup)+/project`)
- and additionally validate at runtime based on `FULLSEND_FORGE`.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
ReviewFindingsHigh
Medium
Low
Next steps:
|
| @@ -6,42 +6,29 @@ | |||
| # mutual-exclusion violations (Story 2, #125). | |||
| # | |||
| # Required env vars: | |||
There was a problem hiding this comment.
[medium] command-injection-path-traversal
FULLSEND_FORGE env var is used as a path component in source "${SCRIPT_DIR}/${FULLSEND_FORGE}/triage-ops.sh" without validating it is one of the expected values ('github' or 'gitlab'). Same pattern in post-triage.sh. While harness-controlled, lacks defense-in-depth validation.
Suggested fix: Add allowlist check before the source line: if FULLSEND_FORGE is not 'github' or 'gitlab', exit with error. Apply to both pre-triage.sh and post-triage.sh.
| remaining=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/labels" --jq "${jq_filter}" 2>/dev/null || echo "VERIFY_FAILED") | ||
|
|
||
| if [[ "${remaining}" == "VERIFY_FAILED" ]]; then | ||
| echo "ERROR: cannot verify label state — API call failed" >&2 |
There was a problem hiding this comment.
[low] jq-filter-injection
In forge_verify_labels_stripped, label names are interpolated directly into a jq filter string via bash string concatenation. Currently called only with hardcoded label constants, but the function is not defensive against future callers with untrusted input.
Suggested fix: Use jq --arg or --argjson to pass label names safely.
| if [[ -n "${filtered}" ]]; then | ||
| filtered="${filtered},${current}" | ||
| else | ||
| filtered="${current}" |
There was a problem hiding this comment.
[low] jq-filter-injection
In forge_remove_label, label name is spliced into a jq expression via single-quote boundary. Currently mitigated by caller-side regex validation in post-triage.sh, but the function itself is not defensive.
Suggested fix: Use jq --arg to safely parameterize the label value.
| @@ -0,0 +1,77 @@ | |||
| --- | |||
| name: issue-labels-gitlab | |||
There was a problem hiding this comment.
[low] skill-name-mismatch
GitLab variant has name: issue-labels-gitlab in frontmatter while GitHub variant has name: issue-labels. The harness injects skills by directory path (not by name), so this is not a runtime bug, but the naming asymmetry is inconsistent and could confuse future contributors.
Suggested fix: Standardize both variants to use the same name (issue-labels) or both to use forge-suffixed names.
|
|
||
| gitlab_api: | ||
| name: gitlab-api | ||
| endpoints: |
There was a problem hiding this comment.
[low] network-policy-scope
The GitLab network policy hardcodes gitlab.cee.redhat.com alongside gitlab.com without documented justification or linked issue. This embeds a customer-specific endpoint in a general-purpose policy.
Suggested fix: Consider moving to a customer-specific policy overlay, or add a comment documenting the justification.
| # Extract host, project path, and issue IID from URL. | ||
| # e.g., https://gitlab.com/group/subgroup/project/-/issues/42 | ||
| GITLAB_HOST=$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|') | ||
| local project_path |
There was a problem hiding this comment.
[low] race-condition
GitLab forge_add_label and forge_remove_label use read-modify-write patterns (GET current labels, compute new set, PUT). Concurrent mutations could silently overwrite each other. GitHub implementation avoids this with dedicated POST/DELETE endpoints per label. Currently mitigated by serial execution in post-triage.sh.
| @@ -0,0 +1,2 @@ | |||
| export GITLAB_ISSUE_URL="${GITLAB_ISSUE_URL}" | |||
| export GITLAB_TOKEN="${GITLAB_TOKEN}" | |||
There was a problem hiding this comment.
The gitlab token shouldn't be in an env var, it should be part of an openshell provider that hosts the gitlab token so the credential doesn't enter the openshell sandbox but it's filled by the provider when the sandbox queries gitlab
waynesun09
left a comment
There was a problem hiding this comment.
Additional finding not anchorable to a line in this diff (file untouched by this PR):
CRITICAL: skill reorg leaves the unrelated review agent's issue-labels skill pointing at a directory with no SKILL.md
harness/review.yaml:16 — This PR moves skills/issue-labels/SKILL.md to skills/issue-labels/github/SKILL.md and adds a sibling skills/issue-labels/gitlab/SKILL.md; skills/issue-labels/ now contains only the github/ and gitlab/ subdirectories, with no SKILL.md directly under it. harness/triage.yaml was correctly updated to the new per-forge paths, but harness/review.yaml (not touched by this PR) still lists the old top-level skills/issue-labels path, and agents/review.md's frontmatter still declares skills: - issue-labels unchanged. The review agent is single-forge/GitHub-only and unrelated to this PR's triage work but shares this skill; after merge, its issue-labels skill mount resolves to a directory with no SKILL.md, silently breaking (or failing to load) its label recommendations.
Suggestion: update harness/review.yaml to reference skills/issue-labels/github (and update agents/review.md's frontmatter to match), or restore a top-level skills/issue-labels/SKILL.md that re-exports/dispatches to the forge-specific variant for backward compatibility. Grep the repo for every consumer of a renamed skill path before merging a skill reorg.
| exit 0 | ||
| CURLMOCK | ||
| # Fix the log path placeholder. | ||
| sed -i "s|CURL_LOG_PLACEHOLDER|${CURL_LOG}|" "${MOCK_BIN}/curl" |
There was a problem hiding this comment.
CRITICAL: GNU-only sed -i here aborts the whole test run on macOS/BSD sed
This line is sed -i "s|CURL_LOG_PLACEHOLDER|${CURL_LOG}|" "${MOCK_BIN}/curl". GNU sed accepts a bare -i, but BSD/macOS sed requires an explicit (possibly empty) backup-suffix argument. Combined with set -euo pipefail at the top of this script, this line errors out on macOS and kills the entire test run right after the last GitHub test — before any of the new run_gitlab_test* invocations execute. That directly contradicts the PR description's claim that all 112 tests (101 GitHub + 11 GitLab) pass.
Suggestion: avoid sed -i for this single-word substitution — write the mock curl script with ${CURL_LOG} already expanded via a heredoc (dropping the placeholder+sed step), or use perl -pi -e, or the portable sed -i.bak ... && rm -f file.bak form that works on both GNU and BSD sed.
| GITLAB_HOST=$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|') | ||
| local project_path | ||
| project_path=$(echo "${ISSUE_URL}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|') | ||
| REPO=$(printf '%s' "${project_path}" | jq -sRr @uri) |
There was a problem hiding this comment.
HIGH: REPO is URL-encoded here, but post-triage.sh's same-repo allowlist check compares it against a plain path
forge_parse_issue_url sets REPO=$(printf '%s' "${project_path}" | jq -sRr @uri) — URL-encoded, e.g. group%2Fproject. In scripts/post-triage.sh, is_target_allowed() has a "source repo is always allowed" fallback: if [[ "${target_repo}" == "${REPO}" ]]; then return 0; fi. target_repo there comes from the agent's JSON as a plain path like group/project. "group/project" == "group%2Fproject" is always false, so on GitLab a triage recommendation to create a prerequisite issue in the very repo being triaged is always rejected as "not in create_issues.allow_targets" unless separately whitelisted. Note that forge_create_issue below (line ~186) correctly re-encodes its own local encoded_target before use — showing URL-encoding is the deliberate convention that the plain string-equality check in post-triage.sh violates. No GitLab test exercises the prerequisites/create-issue path, so this is untested.
Suggestion: keep REPO in a forge-neutral, human-readable form (e.g. group/project) for comparison/logging on both forges, and URL-encode only at the point of building the GitLab API URL (as forge_create_issue's local encoded_target already does). Add a GitLab test for the prerequisites/create action targeting the source repo.
| doc: docs/triage.md | ||
| model: opus | ||
| image: ghcr.io/fullsend-ai/fullsend-sandbox:latest | ||
| policy: policies/triage.yaml |
There was a problem hiding this comment.
HIGH: removing all top-level fallbacks means any invocation that doesn't resolve a forge silently loses policy/skills/env, then crashes on an unbound var
This PR removes this top-level policy: line (and, further down, the top-level skills:/host_files:/issue-token env: entries), moving everything exclusively under forge.github/forge.gitlab. The fullsend CLI's detectForgePlatform returns an empty forge with no error when neither --forge nor GITHUB_ACTIONS/GITLAB_CI env vars are set, and Harness.ResolveForge("") is a documented no-op that leaves Policy/Skills/HostFiles/Env at their top-level values — which are now all empty except the unrelated TRIAGE_AUTO_CODE* vars. Harness.Validate() doesn't require a non-empty Policy, so fullsend run proceeds anyway, and pre-triage.sh (set -euo pipefail) then dies with an unbound-variable error on source "${SCRIPT_DIR}/${FULLSEND_FORGE}/triage-ops.sh".
This is a real, distinct failure mode from the existing CI-path-extraction comments (select-eval-agents.sh) — this is about a runtime crash/silent config loss when the forge can't be resolved at all, e.g. from eval/scripts/run-fullsend.sh, which invokes fullsend run with no --forge flag and doesn't export GITHUB_ACTIONS (it only "works" today by accident when actually run inside a real GH Actions job).
Suggestion: either pass --forge github explicitly from the eval runner (it's GitHub-only today) or export GITHUB_ACTIONS=true for that invocation, and more broadly, have ResolveForge (or the pre-script) fail with a clear, actionable error when no forge resolves, instead of silently proceeding with an empty policy/env.
| skills: | ||
| - issue-labels | ||
| tools: Bash(gh,jq) | ||
| tools: Bash(gh,curl,jq) |
There was a problem hiding this comment.
HIGH: shared tools: Bash(gh,curl,jq) grant now lets the GitHub-forge agent invoke curl, contradicting the documented sandbox hardening rationale
This frontmatter line applies to both forges — there's no per-forge tool-scoping mechanism in this format. policies/github/triage.yaml (unchanged by this PR) carries the explicit comment "curl excluded from the binary allowlist to prevent raw HTTP access with the injected GH_TOKEN", and its github_api network_policy binaries list still only permits **/gh and **/node. So the network-layer control is technically still in place, but the tool-level allowlist that previously made curl uninvocable by the agent at all on GitHub now permits it, relying entirely on the (best-effort, per landlock: compatibility: best_effort) network-policy layer as the sole remaining control. This is a real narrowing of defense-in-depth introduced solely to accommodate GitLab's curl need.
Suggestion: if the harness format supports it, scope curl to the GitLab forge only (per-forge tool override). If not, at minimum add a comment next to this tools: line and in policies/github/triage.yaml acknowledging curl is now permitted at the tool layer and confirming the network-policy layer is verified to still block it for the GH_TOKEN-bearing GitHub sandbox.
| local response | ||
| response=$(_gitlab_api POST "/projects/${encoded_target}/issues" \ | ||
| --data-urlencode "title=${title}" \ | ||
| --data-urlencode "description=${body}") || { |
There was a problem hiding this comment.
MEDIUM: forge_create_issue swallows curl's actual error detail on failure, producing an empty warning message
_gitlab_api runs curl --fail --silent --show-error .... With --fail, curl discards the HTTP response body from stdout on a >=400 status, and --show-error only prints to stderr, which the command substitution response=$(_gitlab_api POST ...) here does not capture. On failure, response is empty, so echo "${response}"; return 1 propagates nothing useful. The caller in scripts/post-triage.sh (CREATED_URL=$(forge_create_issue ...) || { echo "::warning::Failed to create issue in '${TARGET_REPO}': ${CREATED_URL}"; ...}) ends up posting a warning with an empty error suffix, unlike the GitHub path which captures gh issue create ... 2>&1 and preserves the real CLI error text in the same user-facing message.
Suggestion: capture stderr too (e.g. _gitlab_api POST ... 2>&1 and drop --fail, checking HTTP status separately, or use curl -w '\n%{http_code}') so failures surface an actionable error message instead of an empty string.
| "pattern": "^https://github\\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/pull/[0-9]+$" | ||
| "anyOf": [ | ||
| { "pattern": "^https://github\\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/pull/[0-9]+$" }, | ||
| { "pattern": "^https://[a-zA-Z0-9._-]+/.+/-/merge_requests/[0-9]+$" } |
There was a problem hiding this comment.
MEDIUM: GitLab issue/MR URL schema pattern is far looser than its GitHub sibling and the runtime validator, and gets echoed verbatim into public comments
This GitLab anyOf pattern (and the sibling one for prerequisites.existing[].url) uses an unconstrained .+ for the project-path segment: ^https://[a-zA-Z0-9._-]+/.+/-/(merge_requests|issues)/[0-9]+$. This is looser than both the GitHub sibling pattern on the same lines ([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+) and the hand-written runtime validator forge_validate_issue_url in scripts/gitlab/triage-ops.sh, which requires (/[a-zA-Z0-9._-]+)+. This is a separate field/issue from the existing comment about the prerequisites.create.repo pattern below — that one concerns the repo-only field and its mismatch with GitHub's gh issue create --repo, not this URL field's looseness. A schema-valid-but-validator-rejecting URL (containing spaces, brackets, extra segments) would still be interpolated verbatim into the public issue/MR comment body in post-triage.sh (**Addressed by:**\n- ${url}), since the schema is the only guardrail on shape before posting.
Suggestion: tighten the GitLab URL patterns to mirror the character restrictions already used in forge_validate_issue_url, e.g. ^https://[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+/-/(issues|merge_requests)/[0-9]+$.
Summary
scripts/github/triage-ops.sh,scripts/gitlab/triage-ops.sh) with a sharedforge_*function interfacepolicies/{github,gitlab}/,env/{github,gitlab}/,skills/{github,gitlab}/,skills/issue-labels/{github,gitlab}/ISSUE_URL, generic terminology, delegates CLI commands to forge skills)Dependencies
PolicyinForgeConfig(merged)skillsandhost_filesfetch from URL bases (merged)Test plan
bash scripts/post-triage-test.sh) — 101 GitHub + 11 GitLab🤖 Generated with Claude Code