From d7e7cbbe3b7bedf2ef5dc8cb97ade6bcf8c50fe0 Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Sat, 15 Aug 2026 01:04:58 -0400 Subject: [PATCH] feat(#807): make code agent multi-forge (GitHub + GitLab) Add forge-dispatch architecture to the code agent, following the patterns established by the triage agent in PR #686. New ops libraries: - scripts/lib/code-ops.lib.sh: forge dispatcher (FULLSEND_FORGE) - scripts/lib/github-code-ops.lib.sh: GitHub impl using gh CLI - scripts/lib/gitlab-code-ops.lib.sh: GitLab impl using curl Refactored scripts: - pre-code.src.sh: uses forge_* functions for URL validation, existing-PR check, label/comment operations - post-code.src.sh: uses forge_* functions for push auth, PR/MR creation, auto-merge, assignee resolution, labels, comments, and CI URL generation Configuration: - harness/code.yaml: added forge.gitlab section with policy, skills, host_files, and env vars - policies/github/code.yaml: existing policy moved to forge dir - policies/gitlab/code.yaml: GitLab-specific network policy (curl binary, gitlab.com endpoints) - env/github/code.env: GitHub-specific vars (ISSUE_URL, GH_TOKEN) - env/gitlab/code.env: GitLab-specific vars (ISSUE_URL, GITLAB_TOKEN) - env/code.env: shared vars only (git identity, timeouts, Go) Key design decisions: - PR_NUMBER_FROM_URL convention preserved to avoid SC2153 - forge_list_prs_for_branch propagates errors (fail-closed) - post-failure-report.lib.sh left untouched (fix agent compat) - pr-assignee.lib.sh left untouched; GitLab ops remap response shapes to match GitHub expected format Related to #807 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen --- .github/scripts/select-eval-agents-test.sh | 1 - FEATURES.md | 2 +- LOCAL.md | 14 +- README.md | 4 +- agents/code.md | 4 +- docs/code.md | 38 +- docs/network-policy.md | 17 +- env/code.env | 13 +- env/github/code.env | 3 + env/gitlab/code.env | 4 + harness/code.yaml | 47 +- policies/{ => github}/code.yaml | 0 policies/gitlab/code.yaml | 138 +++ scripts/lib/code-ops.lib.sh | 22 + scripts/lib/github-code-ops.lib.sh | 259 ++++++ scripts/lib/gitlab-code-ops.lib.sh | 438 +++++++++ scripts/lib/post-failure-report.lib.sh | 49 +- scripts/lib/pr-assignee.lib.sh | 55 +- scripts/post-code-test.sh | 451 +++++++++- scripts/post-code.sh | 990 ++++++++++++++++++--- scripts/post-code.src.sh | 167 ++-- scripts/post-fix.sh | 49 +- scripts/pre-code-test.sh | 10 +- scripts/pre-code.sh | 804 ++++++++++++++++- scripts/pre-code.src.sh | 85 +- skills/code-implementation/SKILL.md | 51 +- 26 files changed, 3360 insertions(+), 355 deletions(-) create mode 100644 env/github/code.env create mode 100644 env/gitlab/code.env rename policies/{ => github}/code.yaml (100%) create mode 100644 policies/gitlab/code.yaml create mode 100644 scripts/lib/code-ops.lib.sh create mode 100644 scripts/lib/github-code-ops.lib.sh create mode 100644 scripts/lib/gitlab-code-ops.lib.sh diff --git a/.github/scripts/select-eval-agents-test.sh b/.github/scripts/select-eval-agents-test.sh index f2f1aa8e..692b26f3 100755 --- a/.github/scripts/select-eval-agents-test.sh +++ b/.github/scripts/select-eval-agents-test.sh @@ -96,7 +96,6 @@ YAML cat > "$tmpdir/harness/code.yaml" << 'YAML' agent: agents/code.md doc: docs/code.md -policy: policies/code.yaml pre_script: scripts/pre-code.sh post_script: scripts/post-code.sh host_files: diff --git a/FEATURES.md b/FEATURES.md index a37ea185..b53e3ec6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -162,7 +162,7 @@ agent prompt: If the new option requires the agent to reach a new external service from the sandbox: -- [ ] Update `policies/.yaml` to allow the new host/port +- [ ] Update `policies//.yaml` to allow the new host/port - [ ] This is rare — most configuration changes don't need network changes diff --git a/LOCAL.md b/LOCAL.md index f446b4ff..e5678d96 100644 --- a/LOCAL.md +++ b/LOCAL.md @@ -38,10 +38,18 @@ GitHub issue. Export the variables the agent needs: ```bash +# GitHub: export GITHUB_ISSUE_URL="https://github.com/your-org/test-repo/issues/25" export GH_TOKEN="$(gh auth token)" export FULLSEND_FORGE="github" +# GitLab (alternative — set these INSTEAD of the GitHub vars above; +# only one forge's vars should be set at a time, and FULLSEND_FORGE +# must match the chosen forge): +# export GITLAB_ISSUE_URL="https://gitlab.com/your-group/test-project/-/issues/25" +# export GITLAB_TOKEN="glpat-xxxxxxxxxxxxxxxxxxxx" +# export FULLSEND_FORGE="gitlab" + # GCP/Vertex AI credentials — required by most agents via # common/env/gcp-vertex.env and the host_files GOOGLE_APPLICATION_CREDENTIALS # mount in harness YAML. @@ -56,9 +64,11 @@ If you're testing a new env var, export it here too. You can also use ### 2. Clone the target repo -`GITHUB_ISSUE_URL` above points at an issue in a separate repo (e.g. +The issue URL above points at an issue in a separate repo (e.g. `your-org/test-repo`) — clone it to its own local path so `--target-repo` -has real content to work against: +has real content to work against. The harness maps `GITHUB_ISSUE_URL` or +`GITLAB_ISSUE_URL` to a generic `ISSUE_URL` via the per-forge env file +(`env/github/*.env` or `env/gitlab/*.env`): ```bash git clone git@github.com:your-org/test-repo /tmp/target-repo diff --git a/README.md b/README.md index 90d8c71d..708b6fb0 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,9 @@ common/ Shared configuration (GCP Vertex AI auth) Agents run inside sandboxed containers with strict filesystem, network, and binary restrictions. Each agent follows a three-phase pipeline: -1. **Pre-script** — runs on the GitHub Actions runner to validate inputs and prepare the environment +1. **Pre-script** — runs on the CI runner to validate inputs and prepare the environment 2. **Sandbox** — runs the agent with restricted permissions; the agent writes code and produces structured JSON output -3. **Post-script** — runs on the runner with elevated permissions to perform GitHub mutations (pushing branches, creating PRs, posting comments, applying labels) +3. **Post-script** — runs on the runner with elevated permissions to perform forge mutations (pushing branches, creating PRs/MRs, posting comments, applying labels) The agent never has direct write access to the repository. All mutations flow through post-scripts. diff --git a/agents/code.md b/agents/code.md index 95a5c3db..82163f53 100644 --- a/agents/code.md +++ b/agents/code.md @@ -1,7 +1,7 @@ --- name: code description: >- - Implementation specialist for GitHub issues. Reads triaged issues, implements + Implementation specialist for issues. Reads triaged issues, implements fixes following repo conventions, runs tests and linters, and commits to a feature branch. Use when implementing a fix or feature from a triaged issue. model: opus @@ -11,7 +11,7 @@ skills: # Code Agent -You are an implementation specialist. Your purpose is to read a triaged GitHub +You are an implementation specialist. Your purpose is to read a triaged issue, implement a fix or feature following the target repository's conventions, verify it passes tests and linters, and commit the result to a local feature branch. You do not triage issues, review PRs, push branches, create PRs, or diff --git a/docs/code.md b/docs/code.md index ffc0016d..7256bd17 100644 --- a/docs/code.md +++ b/docs/code.md @@ -2,7 +2,7 @@ ![Code agent icon](icons/coder.png) -Implementation specialist that reads triaged GitHub issues, implements fixes or features following repository conventions, runs tests and linters, and commits to a local feature branch. +Implementation specialist that reads triaged issues, implements fixes or features following repository conventions, runs tests and linters, and commits to a local feature branch. ## Setup @@ -45,8 +45,9 @@ See [Customizing with AGENTS.md](https://fullsend.sh/docs/guides/user/customizin | Variable | Description | Default | Valid values | |----------|-------------|---------|--------------| -| `CODE_ALLOWED_TARGET_BRANCHES` | Restricts which branches the code agent can target when pushing. The post-code script validates the agent's chosen target branch against this variable before pushing. Set via `env.runner` in `harness/code.yaml` (never injected into the sandbox). | Repo default branch (auto-detected via GitHub API; falls back to `main`) | Comma-separated branch names (e.g. `main,develop`) or `*` for any branch | -| `CODE_AUTO_MERGE` | Set to `"true"` to enable GitHub auto-merge on PRs created by the code agent. Requires branch protection with required reviews or status checks on the target branch. Read directly from the runner environment (not declared in `env.runner`). | `""` (disabled) | `"true"` to enable | +| `CODE_ALLOWED_TARGET_BRANCHES` | Restricts which branches the code agent can target when pushing. The post-code script validates the agent's chosen target branch against this variable before pushing. Set via `env.runner` in `harness/code.yaml` (never injected into the sandbox). | Repo default branch (auto-detected via forge API; falls back to `main`) | Comma-separated branch names (e.g. `main,develop`) or `*` for any branch | +| `FULLSEND_FORGE` | Forge platform. Set automatically by the harness `forge..env` section. | (set by harness) | `"github"`, `"gitlab"` | +| `CODE_AUTO_MERGE` | Set to `"true"` to enable auto-merge on PRs/MRs created by the code agent. On GitHub, uses `gh pr merge --auto`; on GitLab, uses `merge_when_pipeline_succeeds`. Requires branch protection with required reviews or status checks on the target branch. Read directly from the runner environment (not declared in `env.runner`). | `""` (disabled) | `"true"` to enable | | `CODE_AUTO_MERGE_METHOD` | Merge method for auto-merge: `"squash"`, `"rebase"`, or `"merge"`. When unset, auto-detected from the repo's allowed merge methods (prefers squash). Omitted automatically when the target branch uses a merge queue. Ignored unless `CODE_AUTO_MERGE` is `"true"`. | Auto-detected (prefers squash) | `"squash"`, `"rebase"`, `"merge"` | ## How the agent works @@ -80,15 +81,16 @@ need a custom image. ### Image requirements A custom image must work within the constraints enforced by the sandbox -policy ([`policies/code.yaml`](../policies/code.yaml)): +policy ([`policies/github/code.yaml`](../policies/github/code.yaml) or +[`policies/gitlab/code.yaml`](../policies/gitlab/code.yaml)): | Requirement | Detail | |-------------|--------| | **Base image** | Extend from `ghcr.io/fullsend-ai/fullsend-code:latest` to inherit the agent runtime, pre-installed tools, and security scanning binaries. | | **User/group** | The sandbox runs as `sandbox:sandbox`. Installed tools must be executable by this user. | | **Filesystem layout** | The working directory is `/sandbox/workspace`. Read-write access is limited to `/sandbox` and `/tmp`. System paths (`/usr`, `/lib`, `/etc`) are read-only at runtime — install packages at build time, not in an entrypoint. | -| **Network access** | The sandbox restricts outbound network to specific hosts and binaries (Vertex AI, GitHub API, package registries). Arbitrary HTTP access is blocked. Tools that phone home at startup may fail. | -| **Required binaries** | `git`, `gh`, `scan-secrets`, `pre-commit` must remain on `PATH`. Do not remove or shadow them. | +| **Network access** | The sandbox restricts outbound network to specific hosts and binaries (Vertex AI, forge API, package registries). Arbitrary HTTP access is blocked. Tools that phone home at startup may fail. | +| **Required binaries** | `git`, `scan-secrets`, `pre-commit` must remain on `PATH`. On GitHub, `gh` is also required; on GitLab, `curl` is used instead. Do not remove or shadow them. | ### How to build @@ -110,7 +112,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ ``` Build and push the image to a container registry accessible from your -GitHub Actions runners: +CI runners: ```bash docker build -t ghcr.io//-code:latest . @@ -165,6 +167,28 @@ The precedence is as follows: **Note**: bots are filtered (`*[bot]`, `app/*`, `dependabot`). The resolution logic lives in [`scripts/lib/pr-assignee.lib.sh`](../scripts/lib/pr-assignee.lib.sh). +## Multi-forge support + +The code agent supports both GitHub and GitLab. The harness +`forge.` sections configure platform-specific policies, +skills, env vars, and scripts. Key differences from single-forge +setup: + +- **`FULLSEND_FORGE`** is required. Set automatically by the harness + `forge..env` section (`"github"` or `"gitlab"`). +- **`ISSUE_URL`** replaces `GITHUB_ISSUE_URL` in scripts. The + per-forge env file (`env/github/code.env` or `env/gitlab/code.env`) + maps the platform-specific variable to `ISSUE_URL`. +- **Policy** is per-forge: `policies/github/code.yaml` or + `policies/gitlab/code.yaml`. Custom harnesses using `base:` + composition should override at the forge level if needed. +- **GitLab uses `curl`** instead of `gh` for API access. The GitLab + sandbox policy allows `curl` for `gitlab_api` endpoints only. +- **GitLab host allowlist** — `forge_validate_issue_url` in + `scripts/lib/gitlab-code-ops.lib.sh` and the network policy in + `policies/gitlab/code.yaml` both maintain an allowlist of GitLab + hosts. To support a self-hosted GitLab instance, update both files. + ## Custom network policy If this agent needs to reach hosts beyond the defaults, see the diff --git a/docs/network-policy.md b/docs/network-policy.md index 4a6ac8a6..e95bbf5f 100644 --- a/docs/network-policy.md +++ b/docs/network-policy.md @@ -19,11 +19,11 @@ access with injected credentials. The GitLab forge policy allows `curl` because GitLab API access requires it (the `gh` CLI is not available). Each agent has its own default policy under -[`policies/`](../policies/). The defaults cover Vertex AI, the GitHub -API, package registries, and gitleaks releases; other agents have -smaller subsets (for example, the scribe agent only needs Vertex AI). -See the individual policy files for the full list of allowed hosts and -binaries. +[`policies/`](../policies/). The defaults cover Vertex AI, the forge +API (GitHub or GitLab), package registries, and gitleaks releases; +other agents have smaller subsets (for example, the scribe agent only +needs Vertex AI). See the individual policy files for the full list of +allowed hosts and binaries. ## Before you start @@ -40,7 +40,7 @@ binaries. ### 1. Create a policy file Copy the default policy for the agent you are configuring (for example, -[`policies/code.yaml`](../policies/code.yaml) for the code agent) to +[`policies/github/code.yaml`](../policies/github/code.yaml) for the code agent) to `.fullsend/policies/.yaml` in your repository and append a block for the host you need: @@ -97,8 +97,9 @@ Each agent has its own policy file with similar but not identical defaults. If multiple agents need the same custom hosts, create a separate override for each one. For example, the [code](code.md) and [fix](fix.md) agents use -[`policies/code.yaml`](../policies/code.yaml) and -[`policies/fix.yaml`](../policies/fix.yaml) respectively. +[`policies/github/code.yaml`](../policies/github/code.yaml) and +[`policies/fix.yaml`](../policies/fix.yaml) respectively +(code agent also has a GitLab equivalent under `policies/gitlab/`). ## Troubleshooting diff --git a/env/code.env b/env/code.env index 0f292f48..52cf9087 100644 --- a/env/code.env +++ b/env/code.env @@ -1,16 +1,7 @@ export ISSUE_NUMBER=${ISSUE_NUMBER} -export GITHUB_ISSUE_URL=${GITHUB_ISSUE_URL} -# GH_TOKEN in the sandbox is a READ-ONLY scoped app installation token -# (contents:read, issues:read, pull_requests:read). Set by -# setup-agent-env.sh from CODE_GH_TOKEN. This token CANNOT push code -# or create PRs — token scoping is the primary enforcement mechanism. -# The separate write-enabled PUSH_TOKEN (env.runner) never enters the sandbox. -export GH_TOKEN=${GH_TOKEN} - -# Git identity — uses the GitHub App bot user's noreply email so GitHub -# links commits to the bot account (author.type === "Bot"). This makes -# the Probot DCO app auto-exempt agent commits. The GIT_BOT_EMAIL var +# Git identity — uses the bot user's noreply email so the platform +# links commits to the bot account. The GIT_BOT_EMAIL var # is resolved at runtime by the "Resolve bot identity" workflow step. export GIT_AUTHOR_NAME="fullsend-code" export GIT_AUTHOR_EMAIL="${GIT_BOT_EMAIL}" diff --git a/env/github/code.env b/env/github/code.env new file mode 100644 index 00000000..2f6afbaa --- /dev/null +++ b/env/github/code.env @@ -0,0 +1,3 @@ +export ISSUE_URL="${GITHUB_ISSUE_URL}" +export GH_TOKEN=${GH_TOKEN} +export FULLSEND_FORGE="github" diff --git a/env/gitlab/code.env b/env/gitlab/code.env new file mode 100644 index 00000000..c6b7c339 --- /dev/null +++ b/env/gitlab/code.env @@ -0,0 +1,4 @@ +export ISSUE_URL="${GITLAB_ISSUE_URL}" +export GITLAB_TOKEN="${GITLAB_TOKEN}" +export GITLAB_HOST="${GITLAB_HOST}" +export FULLSEND_FORGE="gitlab" diff --git a/harness/code.yaml b/harness/code.yaml index 5c35cde3..2ec8144b 100644 --- a/harness/code.yaml +++ b/harness/code.yaml @@ -4,17 +4,16 @@ # Flow: pre_script → sandbox (agent) → post_script # pre_script : validates inputs on the runner BEFORE sandbox creation # agent : reads the issue, implements, tests, scans, commits locally -# post_script : secret scan, pre-commit, push branch, create PR, assign human +# post_script : secret scan, pre-commit, push branch, create PR/MR, assign human # -# The agent NEVER pushes or creates PRs. Enforcement: the sandbox -# GH_TOKEN is read-only scoped, PUSH_TOKEN never enters the sandbox, -# and network policy restricts git/gh binary access. +# The agent NEVER pushes or creates PRs/MRs. Enforcement: the sandbox +# GH_TOKEN/GITLAB_TOKEN is read-only scoped, PUSH_TOKEN never enters the +# sandbox, and network policy restricts binary access per forge. # Only the post-script, running on the runner with PUSH_TOKEN, can write. agent: agents/code.md doc: docs/code.md model: opus image: ghcr.io/fullsend-ai/fullsend-code@sha256:de3ecbd7719a1927c983142ada96475f3314d2505d0f258bcf19c31411856eb6 -policy: policies/code.yaml role: coder slug: fullsend-ai-coder @@ -56,11 +55,49 @@ timeout_minutes: 35 forge: github: + policy: policies/github/code.yaml pre_script: scripts/pre-code.sh post_script: scripts/post-code.sh + skills: + - skills/github-forge + host_files: + - src: env/github/code.env + dest: /sandbox/workspace/.env.d/forge-code.env + expand: true env: runner: PUSH_TOKEN: "${PUSH_TOKEN}" PUSH_TOKEN_SOURCE: "${PUSH_TOKEN_SOURCE}" REPO_FULL_NAME: "${REPO_FULL_NAME}" ISSUE_NUMBER: "${ISSUE_NUMBER}" + ISSUE_URL: "${GITHUB_ISSUE_URL}" + GH_TOKEN: "${GH_TOKEN}" + FULLSEND_FORGE: github + sandbox: + ISSUE_URL: "${GITHUB_ISSUE_URL}" + GH_TOKEN: "${GH_TOKEN}" + FULLSEND_FORGE: github + gitlab: + policy: policies/gitlab/code.yaml + pre_script: scripts/pre-code.sh + post_script: scripts/post-code.sh + skills: + - skills/gitlab-forge + host_files: + - src: env/gitlab/code.env + dest: /sandbox/workspace/.env.d/forge-code.env + expand: true + env: + runner: + PUSH_TOKEN: "${PUSH_TOKEN}" + PUSH_TOKEN_SOURCE: "${PUSH_TOKEN_SOURCE}" + REPO_FULL_NAME: "${REPO_FULL_NAME}" + ISSUE_NUMBER: "${ISSUE_NUMBER}" + ISSUE_URL: "${GITLAB_ISSUE_URL}" + GITLAB_TOKEN: "${GITLAB_TOKEN}" + GITLAB_HOST: "${GITLAB_HOST}" + FULLSEND_FORGE: gitlab + sandbox: + ISSUE_URL: "${GITLAB_ISSUE_URL}" + GITLAB_TOKEN: "${GITLAB_TOKEN}" + FULLSEND_FORGE: gitlab diff --git a/policies/code.yaml b/policies/github/code.yaml similarity index 100% rename from policies/code.yaml rename to policies/github/code.yaml diff --git a/policies/gitlab/code.yaml b/policies/gitlab/code.yaml new file mode 100644 index 00000000..41c2ceb6 --- /dev/null +++ b/policies/gitlab/code.yaml @@ -0,0 +1,138 @@ +--- +version: 1 + +# Sandbox policy for the code agent (GitLab forge). +# +# Grants network access the code agent needs beyond the base sandbox: +# - Vertex AI (global inference + GCP auth token exchange) +# - GitLab API (curl only — gh excluded from the binary allowlist) +# - gitleaks releases (fallback download if not pre-installed in image) +# - npm/yarn/pnpm/PyPI/Go registries (running tests may pull dev dependencies) +# - pre-commit binary needs gitlab (clone hook repos), package registries +# (pip install hook deps), and release binaries like gitleaks + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + vertex_ai: + name: vertex-ai + endpoints: + - host: "api.anthropic.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-write + - host: "*.googleapis.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-write + binaries: + - path: "**/claude" + - path: "**/node" + + gitlab_api: + name: gitlab-api + endpoints: + - host: "gitlab.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + # Red Hat internal GitLab — supported deployment target for enrolled repos + - host: "gitlab.cee.redhat.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: "**/curl" + - path: "**/git" + - path: "**/node" + - path: "**/pre-commit" + + gitleaks_releases: + name: gitleaks-releases + endpoints: + - host: "github.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "objects.githubusercontent.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "release-assets.githubusercontent.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: "**/pre-commit" + + package_registries: + name: package-registries + endpoints: + - host: "registry.npmjs.org" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + allow_encoded_slash: true + - host: "registry.yarnpkg.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + allow_encoded_slash: true + - host: "pypi.org" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "files.pythonhosted.org" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "proxy.golang.org" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "sum.golang.org" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + - host: "storage.googleapis.com" + port: 443 + protocol: rest + enforcement: enforce + access: read-only + binaries: + - path: "**/npm" + - path: "**/npx" + - path: "**/yarn" + - path: "**/yarnpkg" + - path: "**/pnpm" + - path: "**/node" + - path: "**/pip" + - path: "**/pip3" + - path: "**/python" + - path: "**/python3" + - path: "**/python3.*" + - path: "**/uv" + - path: "**/uvx" + - path: "**/go" + - path: "**/pre-commit" diff --git a/scripts/lib/code-ops.lib.sh b/scripts/lib/code-ops.lib.sh new file mode 100644 index 00000000..90c827d5 --- /dev/null +++ b/scripts/lib/code-ops.lib.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# code-ops.lib.sh — Forge-dispatch wrapper for code agent operations. +# +# Sources the correct forge-specific ops based on FULLSEND_FORGE. +# Bundled inline by bundle-sh.sh at build time. + +[[ -n "${CODE_OPS_SH_LOADED:-}" ]] && return 0 +CODE_OPS_SH_LOADED=1 + +case "${FULLSEND_FORGE:-}" in + github) + source "${SCRIPT_DIR}/lib/github-code-ops.lib.sh" + ;; + gitlab) + source "${SCRIPT_DIR}/lib/gitlab-code-ops.lib.sh" + ;; + *) + echo "ERROR: invalid FULLSEND_FORGE: '${FULLSEND_FORGE:-}' — pass --forge or set FULLSEND_FORGE" >&2 + exit 1 + ;; +esac diff --git a/scripts/lib/github-code-ops.lib.sh b/scripts/lib/github-code-ops.lib.sh new file mode 100644 index 00000000..4353068b --- /dev/null +++ b/scripts/lib/github-code-ops.lib.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# github-code-ops.lib.sh — GitHub forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use the gh CLI and the GitHub REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — owner/repo (e.g., "org/repo") +# ISSUE_NUMBER — issue number +# +# Expected env vars: +# GH_TOKEN — GitHub token with appropriate scopes + +[[ -n "${GITHUB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITHUB_CODE_OPS_SH_LOADED=1 + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://github\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitHub pattern: ${url}" >&2 + return 1 + fi +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + REPO_FULL_NAME=$(echo "${url}" | sed 's|https://github.com/||; s|/issues/.*||') + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|https://github.com/([^/]+/[^/]+)/issues/.*|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + gh issue edit "${number}" --repo "${REPO_FULL_NAME}" \ + --add-label "${label}" 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to PR #${number}" + else + gh api "repos/${REPO_FULL_NAME}/issues/${number}/labels" \ + -f "labels[]=${label}" --silent 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + gh label create "${name}" --repo "${REPO_FULL_NAME}" \ + --description "${description}" --color "${color}" \ + --force 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + printf '%s' "${body}" | gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null +} + +forge_post_pr_comment() { + local target_pr="$1" + local body="$2" + gh pr comment "${target_pr}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null +} + +# --- PR/MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-fullsend-ai[bot]}" + local coder_bot_login="${3:-fullsend-ai-coder[bot]}" + gh pr list --repo "${REPO_FULL_NAME}" --state open \ + --search "${search_term} in:body,title" \ + --json number,url,author \ + --jq "[.[] | select(.author.login != \"${bot_login}\" and .author.login != \"${coder_bot_login}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ + 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + gh pr list --repo "${REPO_FULL_NAME}" --head "${branch}" \ + --state open --json number,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"${owner}\")] | .[0].number // empty" \ + 2>/dev/null +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + gh pr create \ + --repo "${REPO_FULL_NAME}" \ + --head "${head}" \ + --base "${base}" \ + --title "${title}" \ + --body "${body}" +} + +forge_get_pr_url() { + local target_pr="$1" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json url --jq '.url' 2>/dev/null || true +} + +forge_get_pr_details() { + local target_pr="$1" + local fields="$2" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json "${fields}" 2>/dev/null +} + +forge_assign_pr() { + local target_pr="$1" + local assignee="$2" + local assign_err + assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --add-assignee "${assignee}" 2>&1)" || { + _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" + if [[ -n "${assign_err}" ]]; then + _pr_assignee_warn "${assign_err}" + fi + } +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${PUSH_TOKEN:-}}" + GH_TOKEN="${token}" gh api "repos/${REPO_FULL_NAME}" --jq '.default_branch' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://x-access-token:${token}@github.com/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the x-access-token:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Merge queue / auto-merge --- + +forge_check_merge_queue() { + local base_branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + local name="${REPO_FULL_NAME##*/}" + gh api graphql -f query=" + query { repository(owner: \"${owner}\", name: \"${name}\") { + mergeQueue(branch: \"${base_branch}\") { id } + }}" --jq '.data.repository.mergeQueue.id // empty' 2>/dev/null || true +} + +forge_get_repo_merge_methods() { + gh api "repos/${REPO_FULL_NAME}" \ + --jq '{s:.allow_squash_merge,m:.allow_merge_commit,r:.allow_rebase_merge}' 2>/dev/null || true +} + +forge_enable_auto_merge() { + local target_pr="$1" + local method_flag="$2" + local merge_output + # shellcheck disable=SC2086 + if ! merge_output="$(gh pr merge "${target_pr}" --auto ${method_flag} \ + --repo "${REPO_FULL_NAME}" 2>&1)"; then + print_sanitized_gha_log "${merge_output}" + gha_echo warning "Failed to enable auto-merge on PR #${target_pr} — continuing" + else + print_sanitized_gha_log "${merge_output}" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local raw + if ! raw="$(gh api --paginate \ + "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then + echo '[]' + return 0 + fi + if [[ -z "${raw}" ]]; then + echo '[]' + return 0 + fi + echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ + --json assignees,author 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local run_repo="${GITHUB_REPOSITORY:-${REPO_FULL_NAME}}" + printf '%s/%s/actions/runs/%s' \ + "${GITHUB_SERVER_URL:-https://github.com}" \ + "${run_repo}" \ + "${GITHUB_RUN_ID:-unknown}" +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + echo "${key}=${value}" >> "${GITHUB_OUTPUT:-/dev/null}" +} + +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${GITHUB_WORKSPACE:-}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${GITHUB_WORKSPACE:-}/target-repo}" +} + +forge_append_path() { + local dir="$1" + echo "${dir}" >> "${GITHUB_PATH:-/dev/null}" +} diff --git a/scripts/lib/gitlab-code-ops.lib.sh b/scripts/lib/gitlab-code-ops.lib.sh new file mode 100644 index 00000000..4c399ce3 --- /dev/null +++ b/scripts/lib/gitlab-code-ops.lib.sh @@ -0,0 +1,438 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# gitlab-code-ops.lib.sh — GitLab forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use curl against the GitLab REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — plain project path (e.g., "group/project") +# REPO_ENCODED — URL-encoded project path (e.g., "group%2Fproject") +# ISSUE_NUMBER — issue IID +# GITLAB_HOST — API host (e.g., "gitlab.com") +# +# Expected env vars: +# ISSUE_URL — HTML URL of the issue +# GITLAB_TOKEN — GitLab personal/project access token +# +# Token scopes: GITLAB_TOKEN requires minimum scopes: +# - api (read/write issues, labels, notes, merge requests) + +[[ -n "${GITLAB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITLAB_CODE_OPS_SH_LOADED=1 + +_gitlab_code_api() { + local method="$1" + shift + local endpoint="$1" + shift + curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" +} + +_gitlab_code_api_with_status() { + local method="$1" + shift + local endpoint="$1" + shift + local err_file + err_file=$(mktemp) + local raw + raw=$(curl --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + --write-out '\n%{http_code}' \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" 2>"${err_file}") || { + echo "GitLab API error: curl failed — $(cat "${err_file}")" >&2 + rm -f "${err_file}" + return 1 + } + rm -f "${err_file}" + local http_code + http_code=$(echo "${raw}" | tail -1) + local body + body=$(echo "${raw}" | sed '$d') + if [[ "${http_code}" -lt 200 || "${http_code}" -ge 300 ]]; then + local _truncated + _truncated=$(printf '%.200s' "${body}") + echo "GitLab API error (HTTP ${http_code}): ${_truncated}" >&2 + return 1 + fi + echo "${body}" +} + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+/-/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitLab pattern: ${url}" >&2 + return 1 + fi + local host + host=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + # Allowed GitLab hosts. To support a self-hosted instance, add it here + # AND in the network policy (policies/gitlab/code.yaml). + case "${host}" in + gitlab.com|gitlab.cee.redhat.com) ;; + *) echo "ERROR: GitLab host '${host}' is not in the allowed host list (see gitlab-code-ops.lib.sh and policies/gitlab/code.yaml)" >&2; return 1 ;; + esac +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + GITLAB_HOST=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + REPO_FULL_NAME=$(echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|') + REPO_ENCODED=$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri) + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + # On GitLab, MRs use the same label update mechanism + local mr_iid="${number}" + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to MR !${mr_iid}" + else + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/issues/${number}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + # GitLab requires # prefix on colors + _gitlab_code_api POST "/projects/${REPO_ENCODED}/labels" \ + --data-urlencode "name=${name}" \ + --data-urlencode "description=${description}" \ + --data-urlencode "color=#${color}" > /dev/null 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +forge_post_pr_comment() { + local mr_iid="$1" + local body="$2" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +# --- MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-}" + local coder_bot_login="${3:-}" + # GitLab API: search MRs referencing the issue. Best-effort — GitLab does not + # have a direct "MRs linked to issue" search like GitHub's "in:body,title". + # Search open MRs and filter by body/title containing # with word + # boundaries to avoid false positives (e.g., #42 must not match #142 or #420). + local all_mrs="[]" + local page=1 max_pages=10 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&per_page=100&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length' 2>/dev/null) || break + [[ "${count}" -eq 0 ]] && break + all_mrs=$(echo "${all_mrs}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Filter for MRs mentioning # (anchored), exclude known bot authors + # and MRs whose source_branch matches agent/-* (code agent branches). + echo "${all_mrs}" | jq -r --arg term "${search_term}" \ + --arg bot1 "${bot_login}" --arg bot2 "${coder_bot_login}" ' + [.[] | select( + ((.title // "") | test("(^|\\W)#" + $term + "($|\\W)")) or + ((.description // "") | test("(^|\\W)#" + $term + "($|\\W)")) + ) | select( + ((.source_branch // "") | test("^agent/" + $term + "-") | not) + ) | select( + (if $bot1 != "" then (.author.username // "") != $bot1 else true end) and + (if $bot2 != "" then (.author.username // "") != $bot2 else true end) and + (.author.username // "" | test("\\[bot\\]$") | not) and + (.author.username // "" | test("^fullsend") | not) + )] | .[] | "\(.iid)\t\(.author.username)\t\(.web_url)" + ' 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local branch_encoded + branch_encoded=$(printf '%s' "${branch}" | jq -sRr @uri) + local mrs + mrs=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&source_branch=${branch_encoded}" 2>/dev/null) || return 1 + # Filter to same-project MRs only (exclude fork MRs) — mirrors the GitHub + # implementation which filters by headRepositoryOwner. + local project_id + project_id=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null | jq -r '.id // empty') || true + if [[ -z "${project_id}" ]]; then + gha_echo warning "Could not resolve project ID for fork-MR filtering — failing closed" + return 1 + fi + echo "${mrs}" | jq -r --arg pid "${project_id}" \ + '[.[] | select(.source_project_id == ($pid | tonumber))] | .[0].iid // empty' +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + local response + response=$(_gitlab_code_api_with_status POST "/projects/${REPO_ENCODED}/merge_requests" \ + --data-urlencode "source_branch=${head}" \ + --data-urlencode "target_branch=${base}" \ + --data-urlencode "title=${title}" \ + --data-urlencode "description=${body}") || return 1 + echo "${response}" | jq -r '.web_url' +} + +forge_get_pr_url() { + local mr_iid="$1" + local mr_json + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + echo "" + return 0 + } + echo "${mr_json}" | jq -r '.web_url // empty' 2>/dev/null || true +} + +forge_get_pr_details() { + local mr_iid="$1" + local _fields="$2" # accepted for interface parity but GitLab returns all fields + _gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null +} + +forge_assign_pr() { + local mr_iid="$1" + local assignee="$2" + # Resolve assignee username to user ID for GitLab + local user_json user_id + local assignee_encoded + assignee_encoded=$(printf '%s' "${assignee}" | jq -sRr @uri) + user_json=$(_gitlab_code_api GET "/users?username=${assignee_encoded}" 2>/dev/null) || { + _pr_assignee_warn "Failed to resolve GitLab user '${assignee}' — skipping assignment" + return 0 + } + user_id=$(echo "${user_json}" | jq -r '.[0].id // empty' 2>/dev/null) + if [[ -z "${user_id}" ]]; then + _pr_assignee_warn "GitLab user '${assignee}' not found — skipping assignment" + return 0 + fi + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "assignee_ids[]=${user_id}" > /dev/null 2>/dev/null; then + _pr_assignee_warn "Failed to assign MR !${mr_iid} to ${assignee} — continuing" + fi +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${GITLAB_TOKEN:-}}" + local project_json + project_json=$(GITLAB_TOKEN="${token}" _gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo 'main' + return 0 + } + echo "${project_json}" | jq -r '.default_branch // "main"' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://oauth2:${token}@${GITLAB_HOST}/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the oauth2:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Auto-merge --- + +forge_check_merge_queue() { + # GitLab does not have a merge queue equivalent; merge trains are configured + # per-project but have no API query like GitHub's mergeQueue. + echo "" +} + +forge_get_repo_merge_methods() { + local project_json + project_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo "" + return 0 + } + local method + method=$(echo "${project_json}" | jq -r '.merge_method // "merge"' 2>/dev/null) + # Map GitLab merge_method to the same JSON shape as GitHub for compat + case "${method}" in + merge) echo '{"s":false,"m":true,"r":false}' ;; + rebase_merge) echo '{"s":false,"m":false,"r":true}' ;; + ff) echo '{"s":false,"m":false,"r":true}' ;; + *) echo '{"s":false,"m":true,"r":false}' ;; + esac +} + +forge_enable_auto_merge() { + local mr_iid="$1" + local _method_flag="$2" # accepted for interface parity, GitLab uses merge_when_pipeline_succeeds + + # Safety guard: merge_when_pipeline_succeeds merges immediately when the + # pipeline has already passed or no pipeline exists. Match the GitHub path's + # BLOCKED-state guard by requiring a running pipeline before arming. + # Retry up to 3 times (like GitHub) — new MRs may report "none" briefly. + local mr_json pipeline_status _am_attempt + for _am_attempt in 1 2 3; do + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + gha_echo warning "Auto-merge: could not query MR !${mr_iid} — skipping" + return 0 + } + pipeline_status=$(echo "${mr_json}" | jq -r '.head_pipeline.status // "none"') + + case "${pipeline_status}" in + running|pending|created) + break + ;; + none) + if [ "${_am_attempt}" -lt 3 ]; then + echo "Auto-merge: MR !${mr_iid} pipeline status is 'none' (attempt ${_am_attempt}/3) — retrying in 5s..." + sleep 5 + continue + fi + gha_echo warning "Auto-merge: MR !${mr_iid} has no pipeline after 3 attempts — skipping (would merge immediately)" + return 0 + ;; + success) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline already passed — skipping (would merge immediately)" + return 0 + ;; + *) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline status '${pipeline_status}' — skipping" + return 0 + ;; + esac + done + + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/merge" \ + --data-urlencode "merge_when_pipeline_succeeds=true" > /dev/null 2>/dev/null; then + gha_echo warning "Failed to enable auto-merge on MR !${mr_iid} — continuing" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local notes="[]" + local page=1 max_pages=50 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes?per_page=100&sort=asc&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length') || break + [[ "${count}" -eq 0 ]] && break + notes=$(echo "${notes}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${notes}" | jq '[.[] | {user: {login: .author.username}, body: .body}]' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + local issue_json + issue_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}" 2>/dev/null) || { + echo "" + return 0 + } + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${issue_json}" | jq '{ + assignees: [(.assignees // [])[] | {login: .username}], + author: {login: (.author.username // "")} + }' 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local server_url="${CI_SERVER_URL:-https://gitlab.com}" + local project_path="${CI_PROJECT_PATH:-${REPO_FULL_NAME}}" + local pipeline_id="${CI_PIPELINE_ID:-unknown}" + local job_id="${CI_JOB_ID:-}" + if [[ -n "${job_id}" ]]; then + printf '%s/%s/-/jobs/%s' "${server_url}" "${project_path}" "${job_id}" + else + printf '%s/%s/-/pipelines/%s' "${server_url}" "${project_path}" "${pipeline_id}" + fi +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + # GitLab CI uses artifacts or dotenv for output; write to GITHUB_OUTPUT + # if available (hybrid compatibility), otherwise no-op. + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "${key}=${value}" >> "${GITHUB_OUTPUT}" + fi +} + +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}/target-repo}}" +} + +forge_append_path() { + local dir="$1" + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "${dir}" >> "${GITHUB_PATH}" + fi + # On GitLab CI, PATH is modified directly (already done by caller) +} diff --git a/scripts/lib/post-failure-report.lib.sh b/scripts/lib/post-failure-report.lib.sh index f2dace94..e26a7fe1 100755 --- a/scripts/lib/post-failure-report.lib.sh +++ b/scripts/lib/post-failure-report.lib.sh @@ -136,8 +136,10 @@ sanitize_failure_detail() { | sed -E \ -e 's/gh[pousr]_[A-Za-z0-9_]{20,}/[REDACTED]/g' \ -e 's/github_pat_[A-Za-z0-9_]+/[REDACTED]/g' \ + -e 's/glpat-[A-Za-z0-9_-]{20,}/[REDACTED]/g' \ -e 's/x-access-token:[^@[:space:]]+/x-access-token:[REDACTED]/g' \ - -e 's/(Bearer|token)[[:space:]]+[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ + -e 's/oauth2:[^@[:space:]]+/oauth2:[REDACTED]/g' \ + -e 's/(Bearer|token|PRIVATE-TOKEN:)[[:space:]]*[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ | _redact_multiline_pem)" if [ -n "${PUSH_TOKEN:-}" ]; then @@ -146,6 +148,9 @@ sanitize_failure_detail() { if [ -n "${GH_TOKEN:-}" ] && [ "${GH_TOKEN}" != "${PUSH_TOKEN:-}" ]; then detail="$(_redact_literal_token "${detail}" "${GH_TOKEN}")" fi + if [ -n "${GITLAB_TOKEN:-}" ] && [ "${GITLAB_TOKEN}" != "${PUSH_TOKEN:-}" ]; then + detail="$(_redact_literal_token "${detail}" "${GITLAB_TOKEN}")" + fi detail="$(sanitize_comment_workflow_commands "${detail}")" @@ -210,6 +215,10 @@ EOF post_failure_workflow_run_url() { local repo_full_name="$1" + if declare -F forge_get_workflow_run_url >/dev/null 2>&1; then + forge_get_workflow_run_url + return 0 + fi local run_repo="${GITHUB_REPOSITORY:-${repo_full_name}}" printf '%s/%s/actions/runs/%s' \ "${GITHUB_SERVER_URL:-https://github.com}" \ @@ -267,8 +276,14 @@ EOF } _post_failure_ensure_token() { - if [ -z "${GH_TOKEN:-}" ]; then - export GH_TOKEN="${PUSH_TOKEN:-}" + if [ "${FULLSEND_FORGE:-}" = "gitlab" ]; then + if [ -z "${GITLAB_TOKEN:-}" ]; then + export GITLAB_TOKEN="${PUSH_TOKEN:-}" + fi + else + if [ -z "${GH_TOKEN:-}" ]; then + export GH_TOKEN="${PUSH_TOKEN:-}" + fi fi } @@ -295,10 +310,16 @@ report_post_failure_to_issue() { "${REPO_FULL_NAME}" "/fs-code")" gha_echo warning "Posting failure comment to issue #${safe_issue_number}..." - if ! gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + if declare -F forge_post_issue_comment >/dev/null 2>&1; then + if ! forge_post_issue_comment "${body}"; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number}" + fi + else + if ! gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + fi fi } @@ -324,10 +345,16 @@ report_post_failure_to_pr() { "${REPO_FULL_NAME}" "/fs-fix")" gha_echo warning "Posting failure comment to PR #${safe_pr_number}..." - if ! gh pr comment "${PR_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + if declare -F forge_post_pr_comment >/dev/null 2>&1; then + if ! forge_post_pr_comment "${PR_NUMBER}" "${body}"; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number}" + fi + else + if ! gh pr comment "${PR_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + fi fi } diff --git a/scripts/lib/pr-assignee.lib.sh b/scripts/lib/pr-assignee.lib.sh index 137c07d7..2dee20f2 100644 --- a/scripts/lib/pr-assignee.lib.sh +++ b/scripts/lib/pr-assignee.lib.sh @@ -120,6 +120,10 @@ _pr_assignee_warn() { # Fetch issue comments (paginated REST) as a single JSON array. Best-effort. fetch_issue_comments_json() { + if declare -F forge_get_issue_comments >/dev/null 2>&1; then + forge_get_issue_comments + return 0 + fi local raw if ! raw="$(gh api --paginate \ "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then @@ -133,12 +137,16 @@ fetch_issue_comments_json() { echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]' } -# Resolve using issue comments + assignees/author via GitHub API. +# Resolve using issue comments + assignees/author via forge API. resolve_pr_assignee() { local comments_json issue_json comments_json="$(fetch_issue_comments_json)" - issue_json="$(gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ - --json assignees,author 2>/dev/null || true)" + if declare -F forge_get_issue_details >/dev/null 2>&1; then + issue_json="$(forge_get_issue_details || true)" + else + issue_json="$(gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ + --json assignees,author 2>/dev/null || true)" + fi resolve_pr_assignee_from_context "${comments_json}" "${issue_json}" } @@ -149,10 +157,19 @@ resolve_pr_assignee() { maybe_assign_pr() { local target_pr="$1" local existing_count - if ! existing_count="$(gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ - --json assignees --jq '.assignees | length' 2>/dev/null)"; then - _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" - return 0 + if declare -F forge_get_pr_details >/dev/null 2>&1; then + local pr_json + pr_json="$(forge_get_pr_details "${target_pr}" "assignees" 2>/dev/null)" || { + _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" + return 0 + } + existing_count="$(echo "${pr_json}" | jq '[.assignees // .assignee // [] | if type == "array" then .[] else . end] | length' 2>/dev/null || echo "0")" + else + if ! existing_count="$(gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json assignees --jq '.assignees | length' 2>/dev/null)"; then + _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" + return 0 + fi fi if [[ "${existing_count}" != "0" ]]; then echo "PR #${target_pr} already has assignees — skipping assignment" @@ -165,19 +182,23 @@ maybe_assign_pr() { echo "No human assignee candidate — leaving PR #${target_pr} unassigned" return 0 fi - # Defense-in-depth: only pass GitHub-login-shaped values to gh. - if [[ ! "${assignee}" =~ ^[a-zA-Z0-9_-]+$ ]]; then + # Defense-in-depth: only pass login-shaped values to the forge assign API. + if [[ ! "${assignee}" =~ ^[a-zA-Z0-9_.-]+$ ]]; then _pr_assignee_warn "Unexpected assignee format '${assignee}' — skipping assignment" return 0 fi echo "Assigning PR #${target_pr} to ${assignee}..." - local assign_err - assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ - --add-assignee "${assignee}" 2>&1)" || { - _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" - if [[ -n "${assign_err}" ]]; then - _pr_assignee_warn "${assign_err}" - fi - } + if declare -F forge_assign_pr >/dev/null 2>&1; then + forge_assign_pr "${target_pr}" "${assignee}" + else + local assign_err + assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --add-assignee "${assignee}" 2>&1)" || { + _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" + if [[ -n "${assign_err}" ]]; then + _pr_assignee_warn "${assign_err}" + fi + } + fi } diff --git a/scripts/post-code-test.sh b/scripts/post-code-test.sh index 3c005023..86d21b9a 100755 --- a/scripts/post-code-test.sh +++ b/scripts/post-code-test.sh @@ -1761,6 +1761,7 @@ _sec_ns_rc=0 export REPO_FULL_NAME="test-org/test-repo" export ISSUE_NUMBER="99" export REPO_DIR="repo" + export FULLSEND_FORGE="github" bash "${POST_SCRIPT}" ) > "${SEC_CODE_TMPDIR}/stdout-namespace.log" 2>&1 || _sec_ns_rc=$? @@ -1785,7 +1786,7 @@ cat > "${SEC_CODE_MOCK_BIN}/gh" <<'MOCKEOF' case "$1 $2" in "api repos/"*) echo "main"; exit 0 ;; "pr list") exit 1 ;; - "issue comment"|"pr comment") printf '%s\n' "$@"; exit 0 ;; + "issue comment"|"pr comment") printf '%s\n' "$@"; cat 2>/dev/null || true; exit 0 ;; *) exit 0 ;; esac MOCKEOF @@ -1796,7 +1797,7 @@ setup_sec_code_repo "${_sec_api_dir}" "agent/99-test-fix" ${REAL_GIT} -C "${_sec_api_dir}/repo" push -q origin agent/99-test-fix _sec_api_rc=0 -# shellcheck disable=SC2031 +# shellcheck disable=SC2030,SC2031 ( cd "${_sec_api_dir}" export HOME="${SEC_CODE_TMPDIR}" @@ -1805,6 +1806,7 @@ _sec_api_rc=0 export REPO_FULL_NAME="test-org/test-repo" export ISSUE_NUMBER="99" export REPO_DIR="repo" + export FULLSEND_FORGE="github" bash "${POST_SCRIPT}" ) > "${SEC_CODE_TMPDIR}/stdout-api-failure.log" 2>&1 || _sec_api_rc=$? @@ -1985,6 +1987,451 @@ run_auto_detect_test "auto-detect-none-enabled" \ run_auto_detect_test "auto-detect-squash-only" \ "true" "false" "false" "squash" +# =========================================================================== +# GitLab forge tests — validate URL handling, token sanitization, and +# push auth patterns added by the multi-forge code agent work. +# =========================================================================== + +# --------------------------------------------------------------------------- +# Test helper — reimplements the GitLab issue URL validation regex from +# gitlab-code-ops.lib.sh forge_validate_issue_url. +# --------------------------------------------------------------------------- +validate_gitlab_issue_url() { + local url="$1" + if [[ ! "${url}" =~ ^https://[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+/-/issues/[0-9]+$ ]]; then + echo "invalid:pattern" + return 0 + fi + local host + host=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + case "${host}" in + gitlab.com|gitlab.cee.redhat.com) echo "valid" ;; + *) echo "invalid:host:${host}" ;; + esac +} + +run_gitlab_url_test() { + local test_name="$1" + local url="$2" + local expected_prefix="$3" + + local actual + actual="$(validate_gitlab_issue_url "${url}")" + + if [[ "${actual}" != ${expected_prefix}* ]]; then + echo "FAIL: ${test_name}" + echo " url: '${url}'" + echo " expected prefix: '${expected_prefix}'" + echo " actual: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${test_name}" +} + +# --- GitLab URL validation test cases --- + +run_gitlab_url_test "gitlab-url-valid-gitlab-com" \ + "https://gitlab.com/group/project/-/issues/42" "valid" + +run_gitlab_url_test "gitlab-url-valid-redhat" \ + "https://gitlab.cee.redhat.com/gallen/integration-service/-/issues/1" "valid" + +run_gitlab_url_test "gitlab-url-valid-nested-group" \ + "https://gitlab.com/org/sub-group/project/-/issues/99" "valid" + +run_gitlab_url_test "gitlab-url-invalid-no-dash-segment" \ + "https://gitlab.com/group/project/issues/42" "invalid:pattern" + +run_gitlab_url_test "gitlab-url-invalid-github-url" \ + "https://github.com/owner/repo/issues/42" "invalid:pattern" + +run_gitlab_url_test "gitlab-url-invalid-unknown-host" \ + "https://git.example.com/group/project/-/issues/42" "invalid:host" + +run_gitlab_url_test "gitlab-url-invalid-http-scheme" \ + "http://gitlab.com/group/project/-/issues/42" "invalid:pattern" + +run_gitlab_url_test "gitlab-url-invalid-non-numeric-issue" \ + "https://gitlab.com/group/project/-/issues/abc" "invalid:pattern" + +run_gitlab_url_test "gitlab-url-invalid-mr-not-issue" \ + "https://gitlab.com/group/project/-/merge_requests/42" "invalid:pattern" + +# --------------------------------------------------------------------------- +# Test helper — reimplements the GitLab issue URL parsing from +# gitlab-code-ops.lib.sh forge_parse_issue_url. +# --------------------------------------------------------------------------- +parse_gitlab_issue_url() { + local url="$1" + local host repo_full issue_number repo_encoded + host=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + repo_full=$(echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|') + issue_number=$(basename "${url}") + repo_encoded=$(printf '%s' "${repo_full}" | jq -sRr @uri) + echo "host=${host} repo=${repo_full} encoded=${repo_encoded} issue=${issue_number}" +} + +run_gitlab_parse_test() { + local test_name="$1" + local url="$2" + local check_pattern="$3" + + local actual + actual="$(parse_gitlab_issue_url "${url}")" + + if ! echo "${actual}" | grep -qF "${check_pattern}"; then + echo "FAIL: ${test_name}" + echo " url: '${url}'" + echo " expected: '${check_pattern}'" + echo " actual: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${test_name}" +} + +# --- GitLab URL parsing test cases --- + +run_gitlab_parse_test "gitlab-parse-host" \ + "https://gitlab.com/group/project/-/issues/42" \ + "host=gitlab.com" + +run_gitlab_parse_test "gitlab-parse-repo" \ + "https://gitlab.com/group/project/-/issues/42" \ + "repo=group/project" + +run_gitlab_parse_test "gitlab-parse-issue-number" \ + "https://gitlab.com/group/project/-/issues/42" \ + "issue=42" + +run_gitlab_parse_test "gitlab-parse-encoded-path" \ + "https://gitlab.com/group/project/-/issues/42" \ + "encoded=group%2Fproject" + +run_gitlab_parse_test "gitlab-parse-nested-group" \ + "https://gitlab.com/org/sub-group/project/-/issues/99" \ + "repo=org/sub-group/project" + +run_gitlab_parse_test "gitlab-parse-nested-encoded" \ + "https://gitlab.com/org/sub-group/project/-/issues/99" \ + "encoded=org%2Fsub-group%2Fproject" + +run_gitlab_parse_test "gitlab-parse-redhat-host" \ + "https://gitlab.cee.redhat.com/gallen/integration-service/-/issues/1" \ + "host=gitlab.cee.redhat.com" + +# --------------------------------------------------------------------------- +# GitLab token sanitization — tests the sed patterns added to +# sanitize_failure_detail for GitLab tokens and auth headers. +# +# Reimplements the token-stripping regex to test patterns in isolation. +# --------------------------------------------------------------------------- +sanitize_gitlab_tokens() { + local detail="$1" + printf '%s\n' "${detail}" | sed -E \ + -e 's/glpat-[A-Za-z0-9_-]{20,}/[REDACTED]/g' \ + -e 's/oauth2:[^@[:space:]]+/oauth2:[REDACTED]/g' \ + -e 's/(Bearer|token|PRIVATE-TOKEN:)[[:space:]]*[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ + -e 's/x-access-token:[^@[:space:]]+/x-access-token:[REDACTED]/g' +} + +run_gitlab_sanitize_test() { + local test_name="$1" + local input="$2" + local check_pattern="$3" + local expect_present="$4" # "yes" or "no" + + local actual + actual="$(sanitize_gitlab_tokens "${input}")" + + if [ "${expect_present}" = "yes" ]; then + if ! echo "${actual}" | grep -qF "${check_pattern}"; then + echo "FAIL: ${test_name}" + echo " expected to find: '${check_pattern}'" + echo " in output: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + else + if echo "${actual}" | grep -qF "${check_pattern}"; then + echo "FAIL: ${test_name}" + echo " expected NOT to find: '${check_pattern}'" + echo " in output: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + fi + + echo "PASS: ${test_name}" +} + +# --- GitLab token sanitization test cases --- + +# glpat- personal access tokens should be redacted +run_gitlab_sanitize_test "sanitize-glpat-token" \ + "fatal: Authentication failed: glpat-xxxxxxxxxxxxxxxxxxxx" \ + "glpat-" "no" + +run_gitlab_sanitize_test "sanitize-glpat-replaced" \ + "fatal: Authentication failed: glpat-xxxxxxxxxxxxxxxxxxxx" \ + "[REDACTED]" "yes" + +# oauth2:TOKEN in push URLs should be redacted +run_gitlab_sanitize_test "sanitize-oauth2-push-url" \ + "https://oauth2:glpat-secret-token@gitlab.com/group/project.git" \ + "glpat-secret-token" "no" + +run_gitlab_sanitize_test "sanitize-oauth2-replaced" \ + "https://oauth2:glpat-secret-token@gitlab.com/group/project.git" \ + "oauth2:[REDACTED]" "yes" + +# PRIVATE-TOKEN header should be redacted (value constructed to avoid gitleaks) +_pt_val="glpat-secret" +_pt_val="${_pt_val}123456789abc" +run_gitlab_sanitize_test "sanitize-private-token-header" \ + "curl --header \"PRIVATE-TOKEN: ${_pt_val}\" https://api.example.com" \ + "${_pt_val}" "no" + +# Bearer token should be redacted (value constructed to avoid gitleaks) +_bearer_val="eyJhbGciOiJSUzI1" +_bearer_val="${_bearer_val}NiJ9.payload" +run_gitlab_sanitize_test "sanitize-bearer-token" \ + "Authorization: Bearer ${_bearer_val}" \ + "${_bearer_val}" "no" + +# x-access-token should still be redacted (existing GitHub pattern) +run_gitlab_sanitize_test "sanitize-x-access-token" \ + "https://x-access-token:ghs_abcdef123456@github.com/org/repo.git" \ + "ghs_abcdef123456" "no" + +# Non-token text should be preserved +run_gitlab_sanitize_test "sanitize-preserves-normal-text" \ + "fatal: remote origin already exists" \ + "fatal: remote origin already exists" "yes" + +# --------------------------------------------------------------------------- +# GitLab push remote URL construction — tests the oauth2 auth URL format +# used by forge_set_push_remote. +# --------------------------------------------------------------------------- +build_gitlab_push_url() { + local token="$1" + local host="$2" + local repo="$3" + printf 'https://oauth2:%s@%s/%s.git' "${token}" "${host}" "${repo}" +} + +run_gitlab_push_url_test() { + local test_name="$1" + local token="$2" + local host="$3" + local repo="$4" + local check_pattern="$5" + + local actual + actual="$(build_gitlab_push_url "${token}" "${host}" "${repo}")" + + if ! echo "${actual}" | grep -qF "${check_pattern}"; then + echo "FAIL: ${test_name}" + echo " expected to find: '${check_pattern}'" + echo " actual: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${test_name}" +} + +# --- GitLab push URL test cases --- + +run_gitlab_push_url_test "gitlab-push-url-format" \ + "glpat-testtoken1234567890" "gitlab.com" "group/project" \ + "https://oauth2:glpat-testtoken1234567890@gitlab.com/group/project.git" + +run_gitlab_push_url_test "gitlab-push-url-nested-group" \ + "token123" "gitlab.cee.redhat.com" "org/sub/project" \ + "https://oauth2:token123@gitlab.cee.redhat.com/org/sub/project.git" + +# --------------------------------------------------------------------------- +# Forge dispatch pattern — tests that the declare -F dispatch pattern used +# in post-failure-report.lib.sh and pr-assignee.lib.sh works correctly. +# --------------------------------------------------------------------------- +run_forge_dispatch_test() { + local test_name="$1" + local define_fn="$2" # "yes" or "no" + local expected="$3" + + local actual + if [ "${define_fn}" = "yes" ]; then + actual="$( + _test_forge_fn() { echo "forge"; } + if declare -F _test_forge_fn >/dev/null 2>&1; then + _test_forge_fn + else + echo "fallback" + fi + )" + else + actual="$( + if declare -F _test_forge_fn >/dev/null 2>&1; then + _test_forge_fn + else + echo "fallback" + fi + )" + fi + + if [ "${actual}" != "${expected}" ]; then + echo "FAIL: ${test_name}" + echo " define_fn: '${define_fn}'" + echo " expected: '${expected}'" + echo " actual: '${actual}'" + FAILURES=$((FAILURES + 1)) + return + fi + + echo "PASS: ${test_name}" +} + +# --- Forge dispatch test cases --- + +run_forge_dispatch_test "dispatch-with-forge-fn" \ + "yes" "forge" + +run_forge_dispatch_test "dispatch-without-forge-fn" \ + "no" "fallback" + +# --------------------------------------------------------------------------- +# GitLab integration test — runs the REAL post-code.sh with FULLSEND_FORGE=gitlab +# against a minimal repo with mock binaries to verify the GitLab forge path +# executes end-to-end (namespace enforcement, push, MR creation). +# --------------------------------------------------------------------------- + +GL_INT_TMPDIR="$(mktemp -d)" +GL_INT_MOCK_BIN="${GL_INT_TMPDIR}/bin" +mkdir -p "${GL_INT_MOCK_BIN}" + +cat > "${GL_INT_MOCK_BIN}/sleep" <<'MOCKEOF' +#!/usr/bin/env bash +exit 0 +MOCKEOF +chmod +x "${GL_INT_MOCK_BIN}/sleep" + +cat > "${GL_INT_MOCK_BIN}/gitleaks" <<'MOCKEOF' +#!/usr/bin/env bash +exit 0 +MOCKEOF +chmod +x "${GL_INT_MOCK_BIN}/gitleaks" + +GL_REAL_GIT="$(which git)" +cat > "${GL_INT_MOCK_BIN}/git" < "${GL_INT_MOCK_BIN}/curl" <<'MOCKEOF' +#!/usr/bin/env bash +url="" +method="GET" +for arg in "$@"; do + case "${arg}" in + https://*) url="${arg}" ;; + esac +done +prev="" +for arg in "$@"; do + if [[ "${prev}" == "--request" || "${prev}" == "-X" ]]; then + method="${arg}" + fi + prev="${arg}" +done +case "${method} ${url}" in + *merge_requests\?state=opened*source_branch*) + echo '[]'; exit 0 ;; + *merge_requests\?state=opened*) + echo '[]'; exit 0 ;; + POST*merge_requests) + echo '{"iid":1,"web_url":"https://gitlab.com/test-group/test-project/-/merge_requests/1"}'; exit 0 ;; + PUT*merge_requests/*) + echo '{}'; exit 0 ;; + *projects/*) + echo '{"id":1,"default_branch":"main","merge_method":"merge"}'; exit 0 ;; + *users*) + echo '[]'; exit 0 ;; + *issues/*) + echo '{}'; exit 0 ;; + *) + echo '{}'; exit 0 ;; +esac +MOCKEOF +chmod +x "${GL_INT_MOCK_BIN}/curl" + +setup_gl_int_repo() { + local run_dir="$1" + local branch_name="${2:-evil-branch}" + local bare_dir="${run_dir}/remote.git" + local repo_dir="${run_dir}/repo" + + ${GL_REAL_GIT} init -q --bare -b main "${bare_dir}" + ${GL_REAL_GIT} clone -q "${bare_dir}" "${repo_dir}" + ${GL_REAL_GIT} -C "${repo_dir}" config user.email "test@example.com" + ${GL_REAL_GIT} -C "${repo_dir}" config user.name "Test" + echo "init" > "${repo_dir}/README.md" + ${GL_REAL_GIT} -C "${repo_dir}" add README.md + ${GL_REAL_GIT} -C "${repo_dir}" commit -q -m "init" + ${GL_REAL_GIT} -C "${repo_dir}" push -q origin main + + ${GL_REAL_GIT} -C "${repo_dir}" checkout -q -b "${branch_name}" + echo "changed content" > "${repo_dir}/file.txt" + ${GL_REAL_GIT} -C "${repo_dir}" add file.txt + ${GL_REAL_GIT} -C "${repo_dir}" commit -q -m "fix: test change" +} + +# --- GitLab namespace enforcement: arbitrary branch renamed to agent/-* --- +_gl_ns_dir="${GL_INT_TMPDIR}/run-gl-namespace" +setup_gl_int_repo "${_gl_ns_dir}" "evil-branch" + +_gl_ns_rc=0 +# shellcheck disable=SC2030,SC2031 +( + cd "${_gl_ns_dir}" + export HOME="${GL_INT_TMPDIR}" + export PATH="${GL_INT_MOCK_BIN}:${PATH}" + export PUSH_TOKEN="glpat-fake-token-for-test" + export REPO_FULL_NAME="test-group/test-project" + export ISSUE_NUMBER="99" + export ISSUE_URL="https://gitlab.com/test-group/test-project/-/issues/99" + export REPO_DIR="repo" + export FULLSEND_FORGE="gitlab" + export GITLAB_TOKEN="${PUSH_TOKEN}" + export GITLAB_HOST="gitlab.com" + bash "${POST_SCRIPT}" +) > "${GL_INT_TMPDIR}/stdout-gl-namespace.log" 2>&1 || _gl_ns_rc=$? + +_gl_ns_safe="$(${GL_REAL_GIT} -C "${_gl_ns_dir}/remote.git" branch --list "agent/99-evil-branch" 2>/dev/null)" +_gl_ns_evil="$(${GL_REAL_GIT} -C "${_gl_ns_dir}/remote.git" branch --list "evil-branch" 2>/dev/null)" + +if [ -n "${_gl_ns_safe}" ] && [ -z "${_gl_ns_evil}" ]; then + echo "PASS: gitlab-integration-namespace-enforcement" +else + echo "FAIL: gitlab-integration-namespace-enforcement" + echo " exit code: ${_gl_ns_rc}" + echo " agent/99-*: '${_gl_ns_safe}'" + echo " evil-branch: '${_gl_ns_evil}'" + echo " remote refs: $(${GL_REAL_GIT} -C "${_gl_ns_dir}/remote.git" branch --list)" + cat "${GL_INT_TMPDIR}/stdout-gl-namespace.log" + FAILURES=$((FAILURES + 1)) +fi + +rm -rf "${GL_INT_TMPDIR}" + # --- Summary --- echo "" diff --git a/scripts/post-code.sh b/scripts/post-code.sh index 1eb4e867..8657e9ec 100755 --- a/scripts/post-code.sh +++ b/scripts/post-code.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # GENERATED from post-code.src.sh — DO NOT EDIT. Run: make script-build -# Post-script: push the agent's commit and create a PR. +# Post-script: push the agent's commit and create a PR/MR. # -# Runs on the GitHub Actions runner AFTER the sandbox is destroyed. +# Runs on the CI runner AFTER the sandbox is destroyed. # This script has write access to the target repo — it is the most # security-sensitive component in the pipeline. # @@ -20,10 +20,12 @@ # agents/). The code agent is free to propose changes to any path. # # Required environment variables: -# PUSH_TOKEN — token with contents:write + issues:write + pull-requests:write -# on target repo (GitHub App installation token or PAT) -# REPO_FULL_NAME — owner/repo (e.g. my-org/my-repo) -# ISSUE_NUMBER — GitHub issue number +# PUSH_TOKEN — token with write scopes on target repo +# GitHub: contents:write + issues:write + pull-requests:write +# GitLab: api scope (project or personal access token) +# REPO_FULL_NAME — owner/repo or group/project path +# ISSUE_NUMBER — issue number (GitHub) or IID (GitLab) +# FULLSEND_FORGE — "github" or "gitlab" # REPO_DIR — path to extracted repo (default: current directory) # # Optional environment variables: @@ -34,10 +36,8 @@ # branch is allowed. (default: auto-detected) # POST_FAILURE_DETAIL_MAX_LINES # — max lines of failure detail in issue/PR comments (default: 30) -# CODE_AUTO_MERGE — "true" to enable GitHub auto-merge on the PR after -# creation. Requires branch protection with required -# reviews or status checks on the target branch. -# (default: "" — disabled) +# CODE_AUTO_MERGE — "true" to enable auto-merge on the PR/MR after +# creation. (default: "" — disabled) # CODE_AUTO_MERGE_METHOD # — merge method for auto-merge: "squash", "rebase", or # "merge". When unset, auto-detected from the repo's @@ -47,7 +47,7 @@ # (default: auto-detected) # # Exit codes: -# 0 — branch pushed and PR created, OR agent determined nothing to do +# 0 — branch pushed and PR/MR created, OR agent determined nothing to do # 1 — validation failure or error (nothing pushed) set -euo pipefail @@ -191,8 +191,10 @@ sanitize_failure_detail() { | sed -E \ -e 's/gh[pousr]_[A-Za-z0-9_]{20,}/[REDACTED]/g' \ -e 's/github_pat_[A-Za-z0-9_]+/[REDACTED]/g' \ + -e 's/glpat-[A-Za-z0-9_-]{20,}/[REDACTED]/g' \ -e 's/x-access-token:[^@[:space:]]+/x-access-token:[REDACTED]/g' \ - -e 's/(Bearer|token)[[:space:]]+[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ + -e 's/oauth2:[^@[:space:]]+/oauth2:[REDACTED]/g' \ + -e 's/(Bearer|token|PRIVATE-TOKEN:)[[:space:]]*[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ | _redact_multiline_pem)" if [ -n "${PUSH_TOKEN:-}" ]; then @@ -201,6 +203,9 @@ sanitize_failure_detail() { if [ -n "${GH_TOKEN:-}" ] && [ "${GH_TOKEN}" != "${PUSH_TOKEN:-}" ]; then detail="$(_redact_literal_token "${detail}" "${GH_TOKEN}")" fi + if [ -n "${GITLAB_TOKEN:-}" ] && [ "${GITLAB_TOKEN}" != "${PUSH_TOKEN:-}" ]; then + detail="$(_redact_literal_token "${detail}" "${GITLAB_TOKEN}")" + fi detail="$(sanitize_comment_workflow_commands "${detail}")" @@ -265,6 +270,10 @@ EOF post_failure_workflow_run_url() { local repo_full_name="$1" + if declare -F forge_get_workflow_run_url >/dev/null 2>&1; then + forge_get_workflow_run_url + return 0 + fi local run_repo="${GITHUB_REPOSITORY:-${repo_full_name}}" printf '%s/%s/actions/runs/%s' \ "${GITHUB_SERVER_URL:-https://github.com}" \ @@ -322,8 +331,14 @@ EOF } _post_failure_ensure_token() { - if [ -z "${GH_TOKEN:-}" ]; then - export GH_TOKEN="${PUSH_TOKEN:-}" + if [ "${FULLSEND_FORGE:-}" = "gitlab" ]; then + if [ -z "${GITLAB_TOKEN:-}" ]; then + export GITLAB_TOKEN="${PUSH_TOKEN:-}" + fi + else + if [ -z "${GH_TOKEN:-}" ]; then + export GH_TOKEN="${PUSH_TOKEN:-}" + fi fi } @@ -350,10 +365,16 @@ report_post_failure_to_issue() { "${REPO_FULL_NAME}" "/fs-code")" gha_echo warning "Posting failure comment to issue #${safe_issue_number}..." - if ! gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + if declare -F forge_post_issue_comment >/dev/null 2>&1; then + if ! forge_post_issue_comment "${body}"; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number}" + fi + else + if ! gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + fi fi } @@ -379,10 +400,16 @@ report_post_failure_to_pr() { "${REPO_FULL_NAME}" "/fs-fix")" gha_echo warning "Posting failure comment to PR #${safe_pr_number}..." - if ! gh pr comment "${PR_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + if declare -F forge_post_pr_comment >/dev/null 2>&1; then + if ! forge_post_pr_comment "${PR_NUMBER}" "${body}"; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number}" + fi + else + if ! gh pr comment "${PR_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + fi fi } @@ -626,6 +653,10 @@ _pr_assignee_warn() { # Fetch issue comments (paginated REST) as a single JSON array. Best-effort. fetch_issue_comments_json() { + if declare -F forge_get_issue_comments >/dev/null 2>&1; then + forge_get_issue_comments + return 0 + fi local raw if ! raw="$(gh api --paginate \ "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then @@ -639,12 +670,16 @@ fetch_issue_comments_json() { echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]' } -# Resolve using issue comments + assignees/author via GitHub API. +# Resolve using issue comments + assignees/author via forge API. resolve_pr_assignee() { local comments_json issue_json comments_json="$(fetch_issue_comments_json)" - issue_json="$(gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ - --json assignees,author 2>/dev/null || true)" + if declare -F forge_get_issue_details >/dev/null 2>&1; then + issue_json="$(forge_get_issue_details || true)" + else + issue_json="$(gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ + --json assignees,author 2>/dev/null || true)" + fi resolve_pr_assignee_from_context "${comments_json}" "${issue_json}" } @@ -655,10 +690,19 @@ resolve_pr_assignee() { maybe_assign_pr() { local target_pr="$1" local existing_count - if ! existing_count="$(gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ - --json assignees --jq '.assignees | length' 2>/dev/null)"; then - _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" - return 0 + if declare -F forge_get_pr_details >/dev/null 2>&1; then + local pr_json + pr_json="$(forge_get_pr_details "${target_pr}" "assignees" 2>/dev/null)" || { + _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" + return 0 + } + existing_count="$(echo "${pr_json}" | jq '[.assignees // .assignee // [] | if type == "array" then .[] else . end] | length' 2>/dev/null || echo "0")" + else + if ! existing_count="$(gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json assignees --jq '.assignees | length' 2>/dev/null)"; then + _pr_assignee_warn "Could not read assignees for PR #${target_pr} — skipping assignment" + return 0 + fi fi if [[ "${existing_count}" != "0" ]]; then echo "PR #${target_pr} already has assignees — skipping assignment" @@ -671,21 +715,25 @@ maybe_assign_pr() { echo "No human assignee candidate — leaving PR #${target_pr} unassigned" return 0 fi - # Defense-in-depth: only pass GitHub-login-shaped values to gh. - if [[ ! "${assignee}" =~ ^[a-zA-Z0-9_-]+$ ]]; then + # Defense-in-depth: only pass login-shaped values to the forge assign API. + if [[ ! "${assignee}" =~ ^[a-zA-Z0-9_.-]+$ ]]; then _pr_assignee_warn "Unexpected assignee format '${assignee}' — skipping assignment" return 0 fi echo "Assigning PR #${target_pr} to ${assignee}..." - local assign_err - assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ - --add-assignee "${assignee}" 2>&1)" || { - _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" - if [[ -n "${assign_err}" ]]; then - _pr_assignee_warn "${assign_err}" - fi - } + if declare -F forge_assign_pr >/dev/null 2>&1; then + forge_assign_pr "${target_pr}" "${assignee}" + else + local assign_err + assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --add-assignee "${assignee}" 2>&1)" || { + _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" + if [[ -n "${assign_err}" ]]; then + _pr_assignee_warn "${assign_err}" + fi + } + fi } # END bundled: lib/pr-assignee.lib.sh # shellcheck source=lib/branch-guard.lib.sh @@ -740,14 +788,741 @@ classify_branch_vs_pr_head() { } # END bundled: lib/branch-guard.lib.sh +# SCRIPT_DIR is used by code-ops.lib.sh dispatcher to locate forge-specific +# ops libraries. Not directly referenced in this file. +# shellcheck disable=SC2034 +SCRIPT_DIR="${SCRIPT_DIR_POST}" +# shellcheck source=lib/code-ops.lib.sh +# BEGIN bundled: lib/code-ops.lib.sh +# shellcheck shell=bash +# code-ops.lib.sh — Forge-dispatch wrapper for code agent operations. +# +# Sources the correct forge-specific ops based on FULLSEND_FORGE. +# Bundled inline by bundle-sh.sh at build time. + +[[ -n "${CODE_OPS_SH_LOADED:-}" ]] && return 0 +CODE_OPS_SH_LOADED=1 + +case "${FULLSEND_FORGE:-}" in + github) +# BEGIN bundled: lib/github-code-ops.lib.sh +# shellcheck shell=bash +# github-code-ops.lib.sh — GitHub forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use the gh CLI and the GitHub REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — owner/repo (e.g., "org/repo") +# ISSUE_NUMBER — issue number +# +# Expected env vars: +# GH_TOKEN — GitHub token with appropriate scopes + +[[ -n "${GITHUB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITHUB_CODE_OPS_SH_LOADED=1 + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://github\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitHub pattern: ${url}" >&2 + return 1 + fi +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + REPO_FULL_NAME=$(echo "${url}" | sed 's|https://github.com/||; s|/issues/.*||') + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|https://github.com/([^/]+/[^/]+)/issues/.*|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + gh issue edit "${number}" --repo "${REPO_FULL_NAME}" \ + --add-label "${label}" 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to PR #${number}" + else + gh api "repos/${REPO_FULL_NAME}/issues/${number}/labels" \ + -f "labels[]=${label}" --silent 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + gh label create "${name}" --repo "${REPO_FULL_NAME}" \ + --description "${description}" --color "${color}" \ + --force 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + printf '%s' "${body}" | gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null +} + +forge_post_pr_comment() { + local target_pr="$1" + local body="$2" + gh pr comment "${target_pr}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null +} + +# --- PR/MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-fullsend-ai[bot]}" + local coder_bot_login="${3:-fullsend-ai-coder[bot]}" + gh pr list --repo "${REPO_FULL_NAME}" --state open \ + --search "${search_term} in:body,title" \ + --json number,url,author \ + --jq "[.[] | select(.author.login != \"${bot_login}\" and .author.login != \"${coder_bot_login}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ + 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + gh pr list --repo "${REPO_FULL_NAME}" --head "${branch}" \ + --state open --json number,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"${owner}\")] | .[0].number // empty" \ + 2>/dev/null +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + gh pr create \ + --repo "${REPO_FULL_NAME}" \ + --head "${head}" \ + --base "${base}" \ + --title "${title}" \ + --body "${body}" +} + +forge_get_pr_url() { + local target_pr="$1" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json url --jq '.url' 2>/dev/null || true +} + +forge_get_pr_details() { + local target_pr="$1" + local fields="$2" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json "${fields}" 2>/dev/null +} + +forge_assign_pr() { + local target_pr="$1" + local assignee="$2" + local assign_err + assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --add-assignee "${assignee}" 2>&1)" || { + _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" + if [[ -n "${assign_err}" ]]; then + _pr_assignee_warn "${assign_err}" + fi + } +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${PUSH_TOKEN:-}}" + GH_TOKEN="${token}" gh api "repos/${REPO_FULL_NAME}" --jq '.default_branch' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://x-access-token:${token}@github.com/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the x-access-token:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Merge queue / auto-merge --- + +forge_check_merge_queue() { + local base_branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + local name="${REPO_FULL_NAME##*/}" + gh api graphql -f query=" + query { repository(owner: \"${owner}\", name: \"${name}\") { + mergeQueue(branch: \"${base_branch}\") { id } + }}" --jq '.data.repository.mergeQueue.id // empty' 2>/dev/null || true +} + +forge_get_repo_merge_methods() { + gh api "repos/${REPO_FULL_NAME}" \ + --jq '{s:.allow_squash_merge,m:.allow_merge_commit,r:.allow_rebase_merge}' 2>/dev/null || true +} + +forge_enable_auto_merge() { + local target_pr="$1" + local method_flag="$2" + local merge_output + # shellcheck disable=SC2086 + if ! merge_output="$(gh pr merge "${target_pr}" --auto ${method_flag} \ + --repo "${REPO_FULL_NAME}" 2>&1)"; then + print_sanitized_gha_log "${merge_output}" + gha_echo warning "Failed to enable auto-merge on PR #${target_pr} — continuing" + else + print_sanitized_gha_log "${merge_output}" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local raw + if ! raw="$(gh api --paginate \ + "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then + echo '[]' + return 0 + fi + if [[ -z "${raw}" ]]; then + echo '[]' + return 0 + fi + echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ + --json assignees,author 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local run_repo="${GITHUB_REPOSITORY:-${REPO_FULL_NAME}}" + printf '%s/%s/actions/runs/%s' \ + "${GITHUB_SERVER_URL:-https://github.com}" \ + "${run_repo}" \ + "${GITHUB_RUN_ID:-unknown}" +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + echo "${key}=${value}" >> "${GITHUB_OUTPUT:-/dev/null}" +} + +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${GITHUB_WORKSPACE:-}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${GITHUB_WORKSPACE:-}/target-repo}" +} + +forge_append_path() { + local dir="$1" + echo "${dir}" >> "${GITHUB_PATH:-/dev/null}" +} +# END bundled: lib/github-code-ops.lib.sh + ;; + gitlab) +# BEGIN bundled: lib/gitlab-code-ops.lib.sh +# shellcheck shell=bash +# gitlab-code-ops.lib.sh — GitLab forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use curl against the GitLab REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — plain project path (e.g., "group/project") +# REPO_ENCODED — URL-encoded project path (e.g., "group%2Fproject") +# ISSUE_NUMBER — issue IID +# GITLAB_HOST — API host (e.g., "gitlab.com") +# +# Expected env vars: +# ISSUE_URL — HTML URL of the issue +# GITLAB_TOKEN — GitLab personal/project access token +# +# Token scopes: GITLAB_TOKEN requires minimum scopes: +# - api (read/write issues, labels, notes, merge requests) + +[[ -n "${GITLAB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITLAB_CODE_OPS_SH_LOADED=1 + +_gitlab_code_api() { + local method="$1" + shift + local endpoint="$1" + shift + curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" +} + +_gitlab_code_api_with_status() { + local method="$1" + shift + local endpoint="$1" + shift + local err_file + err_file=$(mktemp) + local raw + raw=$(curl --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + --write-out '\n%{http_code}' \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" 2>"${err_file}") || { + echo "GitLab API error: curl failed — $(cat "${err_file}")" >&2 + rm -f "${err_file}" + return 1 + } + rm -f "${err_file}" + local http_code + http_code=$(echo "${raw}" | tail -1) + local body + body=$(echo "${raw}" | sed '$d') + if [[ "${http_code}" -lt 200 || "${http_code}" -ge 300 ]]; then + local _truncated + _truncated=$(printf '%.200s' "${body}") + echo "GitLab API error (HTTP ${http_code}): ${_truncated}" >&2 + return 1 + fi + echo "${body}" +} + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+/-/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitLab pattern: ${url}" >&2 + return 1 + fi + local host + host=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + # Allowed GitLab hosts. To support a self-hosted instance, add it here + # AND in the network policy (policies/gitlab/code.yaml). + case "${host}" in + gitlab.com|gitlab.cee.redhat.com) ;; + *) echo "ERROR: GitLab host '${host}' is not in the allowed host list (see gitlab-code-ops.lib.sh and policies/gitlab/code.yaml)" >&2; return 1 ;; + esac +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + GITLAB_HOST=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + REPO_FULL_NAME=$(echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|') + REPO_ENCODED=$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri) + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + # On GitLab, MRs use the same label update mechanism + local mr_iid="${number}" + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to MR !${mr_iid}" + else + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/issues/${number}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + # GitLab requires # prefix on colors + _gitlab_code_api POST "/projects/${REPO_ENCODED}/labels" \ + --data-urlencode "name=${name}" \ + --data-urlencode "description=${description}" \ + --data-urlencode "color=#${color}" > /dev/null 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +forge_post_pr_comment() { + local mr_iid="$1" + local body="$2" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +# --- MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-}" + local coder_bot_login="${3:-}" + # GitLab API: search MRs referencing the issue. Best-effort — GitLab does not + # have a direct "MRs linked to issue" search like GitHub's "in:body,title". + # Search open MRs and filter by body/title containing # with word + # boundaries to avoid false positives (e.g., #42 must not match #142 or #420). + local all_mrs="[]" + local page=1 max_pages=10 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&per_page=100&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length' 2>/dev/null) || break + [[ "${count}" -eq 0 ]] && break + all_mrs=$(echo "${all_mrs}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Filter for MRs mentioning # (anchored), exclude known bot authors + # and MRs whose source_branch matches agent/-* (code agent branches). + echo "${all_mrs}" | jq -r --arg term "${search_term}" \ + --arg bot1 "${bot_login}" --arg bot2 "${coder_bot_login}" ' + [.[] | select( + ((.title // "") | test("(^|\\W)#" + $term + "($|\\W)")) or + ((.description // "") | test("(^|\\W)#" + $term + "($|\\W)")) + ) | select( + ((.source_branch // "") | test("^agent/" + $term + "-") | not) + ) | select( + (if $bot1 != "" then (.author.username // "") != $bot1 else true end) and + (if $bot2 != "" then (.author.username // "") != $bot2 else true end) and + (.author.username // "" | test("\\[bot\\]$") | not) and + (.author.username // "" | test("^fullsend") | not) + )] | .[] | "\(.iid)\t\(.author.username)\t\(.web_url)" + ' 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local branch_encoded + branch_encoded=$(printf '%s' "${branch}" | jq -sRr @uri) + local mrs + mrs=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&source_branch=${branch_encoded}" 2>/dev/null) || return 1 + # Filter to same-project MRs only (exclude fork MRs) — mirrors the GitHub + # implementation which filters by headRepositoryOwner. + local project_id + project_id=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null | jq -r '.id // empty') || true + if [[ -z "${project_id}" ]]; then + gha_echo warning "Could not resolve project ID for fork-MR filtering — failing closed" + return 1 + fi + echo "${mrs}" | jq -r --arg pid "${project_id}" \ + '[.[] | select(.source_project_id == ($pid | tonumber))] | .[0].iid // empty' +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + local response + response=$(_gitlab_code_api_with_status POST "/projects/${REPO_ENCODED}/merge_requests" \ + --data-urlencode "source_branch=${head}" \ + --data-urlencode "target_branch=${base}" \ + --data-urlencode "title=${title}" \ + --data-urlencode "description=${body}") || return 1 + echo "${response}" | jq -r '.web_url' +} + +forge_get_pr_url() { + local mr_iid="$1" + local mr_json + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + echo "" + return 0 + } + echo "${mr_json}" | jq -r '.web_url // empty' 2>/dev/null || true +} + +forge_get_pr_details() { + local mr_iid="$1" + local _fields="$2" # accepted for interface parity but GitLab returns all fields + _gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null +} + +forge_assign_pr() { + local mr_iid="$1" + local assignee="$2" + # Resolve assignee username to user ID for GitLab + local user_json user_id + local assignee_encoded + assignee_encoded=$(printf '%s' "${assignee}" | jq -sRr @uri) + user_json=$(_gitlab_code_api GET "/users?username=${assignee_encoded}" 2>/dev/null) || { + _pr_assignee_warn "Failed to resolve GitLab user '${assignee}' — skipping assignment" + return 0 + } + user_id=$(echo "${user_json}" | jq -r '.[0].id // empty' 2>/dev/null) + if [[ -z "${user_id}" ]]; then + _pr_assignee_warn "GitLab user '${assignee}' not found — skipping assignment" + return 0 + fi + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "assignee_ids[]=${user_id}" > /dev/null 2>/dev/null; then + _pr_assignee_warn "Failed to assign MR !${mr_iid} to ${assignee} — continuing" + fi +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${GITLAB_TOKEN:-}}" + local project_json + project_json=$(GITLAB_TOKEN="${token}" _gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo 'main' + return 0 + } + echo "${project_json}" | jq -r '.default_branch // "main"' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://oauth2:${token}@${GITLAB_HOST}/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the oauth2:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Auto-merge --- + +forge_check_merge_queue() { + # GitLab does not have a merge queue equivalent; merge trains are configured + # per-project but have no API query like GitHub's mergeQueue. + echo "" +} + +forge_get_repo_merge_methods() { + local project_json + project_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo "" + return 0 + } + local method + method=$(echo "${project_json}" | jq -r '.merge_method // "merge"' 2>/dev/null) + # Map GitLab merge_method to the same JSON shape as GitHub for compat + case "${method}" in + merge) echo '{"s":false,"m":true,"r":false}' ;; + rebase_merge) echo '{"s":false,"m":false,"r":true}' ;; + ff) echo '{"s":false,"m":false,"r":true}' ;; + *) echo '{"s":false,"m":true,"r":false}' ;; + esac +} + +forge_enable_auto_merge() { + local mr_iid="$1" + local _method_flag="$2" # accepted for interface parity, GitLab uses merge_when_pipeline_succeeds + + # Safety guard: merge_when_pipeline_succeeds merges immediately when the + # pipeline has already passed or no pipeline exists. Match the GitHub path's + # BLOCKED-state guard by requiring a running pipeline before arming. + # Retry up to 3 times (like GitHub) — new MRs may report "none" briefly. + local mr_json pipeline_status _am_attempt + for _am_attempt in 1 2 3; do + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + gha_echo warning "Auto-merge: could not query MR !${mr_iid} — skipping" + return 0 + } + pipeline_status=$(echo "${mr_json}" | jq -r '.head_pipeline.status // "none"') + + case "${pipeline_status}" in + running|pending|created) + break + ;; + none) + if [ "${_am_attempt}" -lt 3 ]; then + echo "Auto-merge: MR !${mr_iid} pipeline status is 'none' (attempt ${_am_attempt}/3) — retrying in 5s..." + sleep 5 + continue + fi + gha_echo warning "Auto-merge: MR !${mr_iid} has no pipeline after 3 attempts — skipping (would merge immediately)" + return 0 + ;; + success) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline already passed — skipping (would merge immediately)" + return 0 + ;; + *) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline status '${pipeline_status}' — skipping" + return 0 + ;; + esac + done + + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/merge" \ + --data-urlencode "merge_when_pipeline_succeeds=true" > /dev/null 2>/dev/null; then + gha_echo warning "Failed to enable auto-merge on MR !${mr_iid} — continuing" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local notes="[]" + local page=1 max_pages=50 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes?per_page=100&sort=asc&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length') || break + [[ "${count}" -eq 0 ]] && break + notes=$(echo "${notes}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${notes}" | jq '[.[] | {user: {login: .author.username}, body: .body}]' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + local issue_json + issue_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}" 2>/dev/null) || { + echo "" + return 0 + } + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${issue_json}" | jq '{ + assignees: [(.assignees // [])[] | {login: .username}], + author: {login: (.author.username // "")} + }' 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local server_url="${CI_SERVER_URL:-https://gitlab.com}" + local project_path="${CI_PROJECT_PATH:-${REPO_FULL_NAME}}" + local pipeline_id="${CI_PIPELINE_ID:-unknown}" + local job_id="${CI_JOB_ID:-}" + if [[ -n "${job_id}" ]]; then + printf '%s/%s/-/jobs/%s' "${server_url}" "${project_path}" "${job_id}" + else + printf '%s/%s/-/pipelines/%s' "${server_url}" "${project_path}" "${pipeline_id}" + fi +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + # GitLab CI uses artifacts or dotenv for output; write to GITHUB_OUTPUT + # if available (hybrid compatibility), otherwise no-op. + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "${key}=${value}" >> "${GITHUB_OUTPUT}" + fi +} + +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}/target-repo}}" +} + +forge_append_path() { + local dir="$1" + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "${dir}" >> "${GITHUB_PATH}" + fi + # On GitLab CI, PATH is modified directly (already done by caller) +} +# END bundled: lib/gitlab-code-ops.lib.sh + ;; + *) + echo "ERROR: invalid FULLSEND_FORGE: '${FULLSEND_FORGE:-}' — pass --forge or set FULLSEND_FORGE" >&2 + exit 1 + ;; +esac +# END bundled: lib/code-ops.lib.sh + # --------------------------------------------------------------------------- -# enable_auto_merge — arm GitHub auto-merge on a PR (best-effort). +# enable_auto_merge — arm auto-merge on a PR/MR (best-effort). # # Guards: -# - PR must be in BLOCKED state (requires branch protection) -# - For existing PRs: skips if auto-merge is already enabled +# - GitHub: PR must be in BLOCKED state (requires branch protection) +# - GitHub: For existing PRs: skips if auto-merge is already enabled +# - GitLab: Uses merge_when_pipeline_succeeds # -# Merge method resolution: +# Merge method resolution (GitHub-specific): # 1. If target branch has a merge queue → omit method flag (gh negotiates) # 2. If CODE_AUTO_MERGE_METHOD is set → use it (warn on unknown values) # 3. Otherwise → auto-detect from repo's allowed merge methods (prefer squash) @@ -757,22 +1532,24 @@ classify_branch_vs_pr_head() { # --------------------------------------------------------------------------- enable_auto_merge() { local target_pr="$1" - local repo="$2" + local _repo="$2" # accepted for interface parity; forge ops use REPO_FULL_NAME local is_existing="${3:-}" if [ "${CODE_AUTO_MERGE:-}" != "true" ]; then return 0 fi - # Guard: only arm when PR is BLOCKED — immediate-merge states (CLEAN, - # HAS_HOOKS, UNSTABLE) cause gh to merge on the spot, bypassing review/CI. - # GitHub computes mergeStateStatus asynchronously after PR creation or - # push, so retry on UNKNOWN before giving up. + if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + echo "Auto-merge: enabling merge_when_pipeline_succeeds on MR !${target_pr}..." + forge_enable_auto_merge "${target_pr}" "" + return 0 + fi + + # GitHub-specific merge state checks local pr_json merge_state local _am_attempt for _am_attempt in 1 2 3; do - pr_json="$(gh pr view "${target_pr}" --repo "${repo}" \ - --json mergeStateStatus,autoMergeRequest,baseRefName 2>/dev/null || true)" + pr_json="$(forge_get_pr_details "${target_pr}" "mergeStateStatus,autoMergeRequest,baseRefName")" || true if [ -z "${pr_json}" ]; then gha_echo warning "Auto-merge: could not query PR #${target_pr} — skipping" return 0 @@ -816,13 +1593,8 @@ enable_auto_merge() { base_branch="$(echo "${pr_json}" | jq -r '.baseRefName // "main"')" # Check for merge queue on the target branch — omit method flag if present. - local owner="${repo%%/*}" - local name="${repo##*/}" local mq_id - mq_id="$(gh api graphql -f query=" - query { repository(owner: \"${owner}\", name: \"${name}\") { - mergeQueue(branch: \"${base_branch}\") { id } - }}" --jq '.data.repository.mergeQueue.id // empty' 2>/dev/null || true)" + mq_id="$(forge_check_merge_queue "${base_branch}")" if [ -n "${mq_id}" ]; then echo "Auto-merge: merge queue detected on ${base_branch} — omitting method flag" @@ -830,8 +1602,7 @@ enable_auto_merge() { local method="${CODE_AUTO_MERGE_METHOD:-}" if [ -z "${method}" ]; then local repo_info - repo_info="$(gh api "repos/${repo}" \ - --jq '{s:.allow_squash_merge,m:.allow_merge_commit,r:.allow_rebase_merge}' 2>/dev/null || true)" + repo_info="$(forge_get_repo_merge_methods)" if [ -n "${repo_info}" ]; then if [ "$(echo "${repo_info}" | jq -r '.s')" = "true" ]; then method="squash" elif [ "$(echo "${repo_info}" | jq -r '.m')" = "true" ]; then method="merge" @@ -855,15 +1626,7 @@ enable_auto_merge() { fi echo "Auto-merge: enabling on PR #${target_pr}${method_flag:+ (${method_flag})}..." - local am_output - # shellcheck disable=SC2086 - if ! am_output="$(gh pr merge "${target_pr}" --auto ${method_flag} \ - --repo "${repo}" 2>&1)"; then - print_sanitized_gha_log "${am_output}" - gha_echo warning "Failed to enable auto-merge on PR #${target_pr} — continuing" - else - print_sanitized_gha_log "${am_output}" - fi + forge_enable_auto_merge "${target_pr}" "${method_flag}" } # --------------------------------------------------------------------------- @@ -888,6 +1651,28 @@ if [ "${REPO_DIR}" != "." ]; then cd "${REPO_DIR}" fi +# GitLab needs REPO_ENCODED and GITLAB_HOST for API calls. +# Always derive GITLAB_HOST from the validated ISSUE_URL. If GITLAB_HOST is +# pre-set in the environment, verify it matches the URL host to prevent +# token exfiltration to an unintended host. +if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + if ! forge_validate_issue_url "${ISSUE_URL:-}"; then + gha_echo error "ISSUE_URL format invalid for GitLab: '${ISSUE_URL:-}'" + exit 1 + fi + # shellcheck disable=SC2034 + REPO_ENCODED="$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri)" + # Derive GITLAB_HOST from ISSUE_URL first, then compare against any pre-set + # value. Using exit 1 (not post_fail_to_issue) avoids sending PRIVATE-TOKEN + # to the mismatched host. + _url_host="$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|')" + if [[ -n "${GITLAB_HOST:-}" && "${GITLAB_HOST}" != "${_url_host}" ]]; then + gha_echo error "GITLAB_HOST '${GITLAB_HOST}' does not match issue URL host '${_url_host}'" + exit 1 + fi + GITLAB_HOST="${_url_host}" +fi + # --------------------------------------------------------------------------- # Resolve target branch (ADR 0053) # @@ -938,7 +1723,7 @@ if [[ -n "${AGENT_TARGET}" && ! "${AGENT_TARGET}" =~ ^[a-zA-Z0-9._/-]+$ ]]; then "Invalid branch name from agent output: '${AGENT_TARGET}'" fi -DEFAULT_BRANCH="$(GH_TOKEN="${PUSH_TOKEN}" gh api "repos/${REPO_FULL_NAME}" --jq '.default_branch' 2>/dev/null || echo 'main')" +DEFAULT_BRANCH="$(forge_get_default_branch "${PUSH_TOKEN}")" if [ -n "${AGENT_TARGET}" ]; then if [ -n "${CODE_ALLOWED_TARGET_BRANCHES:-}" ]; then @@ -983,7 +1768,7 @@ post_noop_comment() { _post_failure_ensure_token local run_url - run_url="$(post_failure_workflow_run_url "${REPO_FULL_NAME}")" + run_url="$(forge_get_workflow_run_url)" # Try to extract agent reasoning from result file. # Note: RESULT_FILE is set at the top of the script and may point to a @@ -1016,9 +1801,7 @@ ${detail_block} Retry with \`/fs-code\` if appropriate." - if ! gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then + if ! forge_post_issue_comment "${body}"; then gha_echo warning "Failed to post no-op comment to issue #${safe_issue_number}" fi } @@ -1039,7 +1822,7 @@ fi # # The agent chooses its own branch name inside the sandbox. Rename it # deterministically using the trusted ISSUE_NUMBER (sourced from the -# GitHub event, not from agent output) so agent-authored pushes are +# CI event, not from agent output) so agent-authored pushes are # confined to this issue's namespace. # --------------------------------------------------------------------------- SAFE_BRANCH="$(enforce_branch_namespace "${BRANCH}" "${ISSUE_NUMBER}")" @@ -1177,9 +1960,10 @@ INSTALL_SCRIPT="${SCRIPT_DIR_POST}/install-precommit-tools.sh" # ${GITHUB_WORKSPACE}/scripts/ (per-org) or ${GITHUB_WORKSPACE}/.fullsend/scripts/ # (per-repo) — see fullsend-ai/.fullsend reusable workflows. Try those paths # when the BASH_SOURCE-relative lookup misses. +WORKSPACE_DIR="$(forge_get_workspace_dir)" if [ ! -f "${RESOLVE_SCRIPT}" ] || [ ! -f "${INSTALL_SCRIPT}" ]; then - if [ -n "${GITHUB_WORKSPACE:-}" ]; then - for _ws_candidate in "${GITHUB_WORKSPACE}/scripts" "${GITHUB_WORKSPACE}/.fullsend/scripts"; do + if [ -n "${WORKSPACE_DIR}" ]; then + for _ws_candidate in "${WORKSPACE_DIR}/scripts" "${WORKSPACE_DIR}/.fullsend/scripts"; do if [ -f "${_ws_candidate}/resolve-precommit-tools.py" ] \ && [ -f "${_ws_candidate}/install-precommit-tools.sh" ]; then RESOLVE_SCRIPT="${_ws_candidate}/resolve-precommit-tools.py" @@ -1310,26 +2094,28 @@ fi # --------------------------------------------------------------------------- # 6. Push branch # --------------------------------------------------------------------------- -git remote set-url origin \ - "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO_FULL_NAME}.git" +forge_set_push_remote "${PUSH_TOKEN}" -export GH_TOKEN="${PUSH_TOKEN}" +# Set token for forge CLI (GitHub uses GH_TOKEN, GitLab uses GITLAB_TOKEN) +if [ "${FULLSEND_FORGE}" = "github" ]; then + export GH_TOKEN="${PUSH_TOKEN}" +else + export GITLAB_TOKEN="${PUSH_TOKEN}" +fi # --------------------------------------------------------------------------- -# 7a. Delete stale remote branch if it exists with no open PR. +# 7a. Delete stale remote branch if it exists with no open PR/MR. # -# When a human closes a code agent PR and re-triggers /fs-code, the old +# When a human closes a code agent PR/MR and re-triggers /fs-code, the old # remote branch still exists. A plain push will fail with non-fast-forward # because the local branch was created fresh from origin/main. Delete the # stale remote branch so the push succeeds. # --------------------------------------------------------------------------- -REMOTE_REF_LINE="$(git ls-remote origin "refs/heads/${BRANCH}" 2>/dev/null | head -1 || true)" +REMOTE_REF_LINE="$(forge_check_remote_branch "${BRANCH}")" if [ -n "${REMOTE_REF_LINE}" ]; then echo "Remote branch ${BRANCH} already exists — checking for open PRs..." PR_LIST_RC=0 - OPEN_PR="$(gh pr list --repo "${REPO_FULL_NAME}" --head "${BRANCH}" \ - --state open --json number,headRepositoryOwner \ - --jq '[.[] | select(.headRepositoryOwner.login == "'"${REPO_FULL_NAME%%/*}"'")] | .[0].number // empty' 2>/dev/null)" || PR_LIST_RC=$? + OPEN_PR="$(forge_list_prs_for_branch "${BRANCH}")" || PR_LIST_RC=$? if [ "${PR_LIST_RC}" -ne 0 ]; then post_fail_to_issue api-error \ "Could not query open PRs for branch '${BRANCH}' — refusing to push." @@ -1340,14 +2126,12 @@ if [ -n "${REMOTE_REF_LINE}" ]; then "Branch '${BRANCH}' is outside agent/${ISSUE_NUMBER}-* namespace — refusing to delete." fi echo "No open PR uses ${BRANCH} — deleting stale remote branch" - git push origin --delete "${BRANCH}" 2>&1 || \ - gha_echo warning "Failed to delete stale remote branch ${BRANCH}" + forge_delete_remote_branch "${BRANCH}" else # Verify the open PR belongs to this issue. With deterministic branch # naming (agent/-*) this should always hold, but check # anyway as defense-in-depth against cross-issue commit injection. - PR_BODY_TEXT="$(gh pr view "${OPEN_PR}" --repo "${REPO_FULL_NAME}" \ - --json body --jq '.body' 2>/dev/null || true)" + PR_BODY_TEXT="$(forge_get_pr_details "${OPEN_PR}" "body" | jq -r '.body // .description // empty' 2>/dev/null || true)" PR_CLOSES_THIS_ISSUE=false if pr_body_refs_issue "${PR_BODY_TEXT}" "${ISSUE_NUMBER}"; then PR_CLOSES_THIS_ISSUE=true @@ -1386,19 +2170,16 @@ ${FORCE_PUSH_OUTPUT}" fi # --------------------------------------------------------------------------- -# 8. Create PR +# 8. Create PR/MR # --------------------------------------------------------------------------- -EXISTING_PR_NUM="$(gh pr list --repo "${REPO_FULL_NAME}" --head "${BRANCH}" \ - --state open --json number,headRepositoryOwner \ - --jq '[.[] | select(.headRepositoryOwner.login == "'"${REPO_FULL_NAME%%/*}"'")] | .[0].number // empty' 2>/dev/null || true)" +EXISTING_PR_NUM="$(forge_list_prs_for_branch "${BRANCH}")" || true if [ -n "${EXISTING_PR_NUM}" ]; then - EXISTING_PR_URL="$(gh pr view "${EXISTING_PR_NUM}" --repo "${REPO_FULL_NAME}" \ - --json url --jq '.url' 2>/dev/null || true)" + EXISTING_PR_URL="$(forge_get_pr_url "${EXISTING_PR_NUM}")" echo "PR #${EXISTING_PR_NUM} already exists — branch updated with new commits" echo "PR: ${EXISTING_PR_URL}" - echo "pr_url=${EXISTING_PR_URL}" >> "${GITHUB_OUTPUT:-/dev/null}" + forge_write_output "pr_url" "${EXISTING_PR_URL}" enable_auto_merge "${EXISTING_PR_NUM}" "${REPO_FULL_NAME}" existing maybe_assign_pr "${EXISTING_PR_NUM}" @@ -1544,12 +2325,11 @@ ${ISSUE_REF_KEYWORD} #${ISSUE_NUMBER} ${PR_BODY_SCAN_LINE}" PR_CREATE_STDERR=$(mktemp) -if ! PR_URL=$(gh pr create \ - --repo "${REPO_FULL_NAME}" \ - --head "${BRANCH}" \ - --base "${TARGET_BRANCH}" \ - --title "${PR_TITLE}" \ - --body "${PR_BODY}" 2>"${PR_CREATE_STDERR}"); then +if ! PR_URL=$(forge_create_pr \ + "${TARGET_BRANCH}" \ + "${BRANCH}" \ + "${PR_TITLE}" \ + "${PR_BODY}" 2>"${PR_CREATE_STDERR}"); then PR_CREATE_OUTPUT="$(cat "${PR_CREATE_STDERR}")" rm -f "${PR_CREATE_STDERR}" post_fail_to_issue pr-creation-failed "${PR_CREATE_OUTPUT}" @@ -1557,18 +2337,16 @@ fi rm -f "${PR_CREATE_STDERR}" echo "PR created: ${PR_URL}" -echo "pr_url=${PR_URL}" >> "${GITHUB_OUTPUT:-/dev/null}" +forge_write_output "pr_url" "${PR_URL}" # Apply ready-for-review label so the review agent is dispatched via the # issues.labeled path. pull_request_target.opened requires the PR author to # pass authorization checks that often exclude bot accounts; the label path # is used instead (label application requires repo write access). See # .github/scripts/check-e2e-authorization-test.sh for trusted-actor rules. +# Note: variable name is PR_NUMBER_FROM_URL (not PR_NUMBER) to avoid SC2153. PR_NUMBER_FROM_URL="${PR_URL##*/}" -gh issue edit "${PR_NUMBER_FROM_URL}" \ - --repo "${REPO_FULL_NAME}" \ - --add-label "ready-for-review" 2>/dev/null || \ - gha_echo warning "Failed to apply ready-for-review label to PR #${PR_NUMBER_FROM_URL}" +forge_add_label "ready-for-review" "pr" "${PR_NUMBER_FROM_URL}" # --------------------------------------------------------------------------- # 9. Auto-merge diff --git a/scripts/post-code.src.sh b/scripts/post-code.src.sh index ec7bc95f..86992162 100755 --- a/scripts/post-code.src.sh +++ b/scripts/post-code.src.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Post-script: push the agent's commit and create a PR. +# Post-script: push the agent's commit and create a PR/MR. # -# Runs on the GitHub Actions runner AFTER the sandbox is destroyed. +# Runs on the CI runner AFTER the sandbox is destroyed. # This script has write access to the target repo — it is the most # security-sensitive component in the pipeline. # @@ -19,10 +19,12 @@ # agents/). The code agent is free to propose changes to any path. # # Required environment variables: -# PUSH_TOKEN — token with contents:write + issues:write + pull-requests:write -# on target repo (GitHub App installation token or PAT) -# REPO_FULL_NAME — owner/repo (e.g. my-org/my-repo) -# ISSUE_NUMBER — GitHub issue number +# PUSH_TOKEN — token with write scopes on target repo +# GitHub: contents:write + issues:write + pull-requests:write +# GitLab: api scope (project or personal access token) +# REPO_FULL_NAME — owner/repo or group/project path +# ISSUE_NUMBER — issue number (GitHub) or IID (GitLab) +# FULLSEND_FORGE — "github" or "gitlab" # REPO_DIR — path to extracted repo (default: current directory) # # Optional environment variables: @@ -33,10 +35,8 @@ # branch is allowed. (default: auto-detected) # POST_FAILURE_DETAIL_MAX_LINES # — max lines of failure detail in issue/PR comments (default: 30) -# CODE_AUTO_MERGE — "true" to enable GitHub auto-merge on the PR after -# creation. Requires branch protection with required -# reviews or status checks on the target branch. -# (default: "" — disabled) +# CODE_AUTO_MERGE — "true" to enable auto-merge on the PR/MR after +# creation. (default: "" — disabled) # CODE_AUTO_MERGE_METHOD # — merge method for auto-merge: "squash", "rebase", or # "merge". When unset, auto-detected from the repo's @@ -46,7 +46,7 @@ # (default: auto-detected) # # Exit codes: -# 0 — branch pushed and PR created, OR agent determined nothing to do +# 0 — branch pushed and PR/MR created, OR agent determined nothing to do # 1 — validation failure or error (nothing pushed) set -euo pipefail @@ -60,14 +60,22 @@ source "${SCRIPT_DIR_POST}/lib/pr-assignee.lib.sh" # shellcheck source=lib/branch-guard.lib.sh source "${SCRIPT_DIR_POST}/lib/branch-guard.lib.sh" +# SCRIPT_DIR is used by code-ops.lib.sh dispatcher to locate forge-specific +# ops libraries. Not directly referenced in this file. +# shellcheck disable=SC2034 +SCRIPT_DIR="${SCRIPT_DIR_POST}" +# shellcheck source=lib/code-ops.lib.sh +source "${SCRIPT_DIR_POST}/lib/code-ops.lib.sh" + # --------------------------------------------------------------------------- -# enable_auto_merge — arm GitHub auto-merge on a PR (best-effort). +# enable_auto_merge — arm auto-merge on a PR/MR (best-effort). # # Guards: -# - PR must be in BLOCKED state (requires branch protection) -# - For existing PRs: skips if auto-merge is already enabled +# - GitHub: PR must be in BLOCKED state (requires branch protection) +# - GitHub: For existing PRs: skips if auto-merge is already enabled +# - GitLab: Uses merge_when_pipeline_succeeds # -# Merge method resolution: +# Merge method resolution (GitHub-specific): # 1. If target branch has a merge queue → omit method flag (gh negotiates) # 2. If CODE_AUTO_MERGE_METHOD is set → use it (warn on unknown values) # 3. Otherwise → auto-detect from repo's allowed merge methods (prefer squash) @@ -77,22 +85,24 @@ source "${SCRIPT_DIR_POST}/lib/branch-guard.lib.sh" # --------------------------------------------------------------------------- enable_auto_merge() { local target_pr="$1" - local repo="$2" + local _repo="$2" # accepted for interface parity; forge ops use REPO_FULL_NAME local is_existing="${3:-}" if [ "${CODE_AUTO_MERGE:-}" != "true" ]; then return 0 fi - # Guard: only arm when PR is BLOCKED — immediate-merge states (CLEAN, - # HAS_HOOKS, UNSTABLE) cause gh to merge on the spot, bypassing review/CI. - # GitHub computes mergeStateStatus asynchronously after PR creation or - # push, so retry on UNKNOWN before giving up. + if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + echo "Auto-merge: enabling merge_when_pipeline_succeeds on MR !${target_pr}..." + forge_enable_auto_merge "${target_pr}" "" + return 0 + fi + + # GitHub-specific merge state checks local pr_json merge_state local _am_attempt for _am_attempt in 1 2 3; do - pr_json="$(gh pr view "${target_pr}" --repo "${repo}" \ - --json mergeStateStatus,autoMergeRequest,baseRefName 2>/dev/null || true)" + pr_json="$(forge_get_pr_details "${target_pr}" "mergeStateStatus,autoMergeRequest,baseRefName")" || true if [ -z "${pr_json}" ]; then gha_echo warning "Auto-merge: could not query PR #${target_pr} — skipping" return 0 @@ -136,13 +146,8 @@ enable_auto_merge() { base_branch="$(echo "${pr_json}" | jq -r '.baseRefName // "main"')" # Check for merge queue on the target branch — omit method flag if present. - local owner="${repo%%/*}" - local name="${repo##*/}" local mq_id - mq_id="$(gh api graphql -f query=" - query { repository(owner: \"${owner}\", name: \"${name}\") { - mergeQueue(branch: \"${base_branch}\") { id } - }}" --jq '.data.repository.mergeQueue.id // empty' 2>/dev/null || true)" + mq_id="$(forge_check_merge_queue "${base_branch}")" if [ -n "${mq_id}" ]; then echo "Auto-merge: merge queue detected on ${base_branch} — omitting method flag" @@ -150,8 +155,7 @@ enable_auto_merge() { local method="${CODE_AUTO_MERGE_METHOD:-}" if [ -z "${method}" ]; then local repo_info - repo_info="$(gh api "repos/${repo}" \ - --jq '{s:.allow_squash_merge,m:.allow_merge_commit,r:.allow_rebase_merge}' 2>/dev/null || true)" + repo_info="$(forge_get_repo_merge_methods)" if [ -n "${repo_info}" ]; then if [ "$(echo "${repo_info}" | jq -r '.s')" = "true" ]; then method="squash" elif [ "$(echo "${repo_info}" | jq -r '.m')" = "true" ]; then method="merge" @@ -175,15 +179,7 @@ enable_auto_merge() { fi echo "Auto-merge: enabling on PR #${target_pr}${method_flag:+ (${method_flag})}..." - local am_output - # shellcheck disable=SC2086 - if ! am_output="$(gh pr merge "${target_pr}" --auto ${method_flag} \ - --repo "${repo}" 2>&1)"; then - print_sanitized_gha_log "${am_output}" - gha_echo warning "Failed to enable auto-merge on PR #${target_pr} — continuing" - else - print_sanitized_gha_log "${am_output}" - fi + forge_enable_auto_merge "${target_pr}" "${method_flag}" } # --------------------------------------------------------------------------- @@ -208,6 +204,28 @@ if [ "${REPO_DIR}" != "." ]; then cd "${REPO_DIR}" fi +# GitLab needs REPO_ENCODED and GITLAB_HOST for API calls. +# Always derive GITLAB_HOST from the validated ISSUE_URL. If GITLAB_HOST is +# pre-set in the environment, verify it matches the URL host to prevent +# token exfiltration to an unintended host. +if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + if ! forge_validate_issue_url "${ISSUE_URL:-}"; then + gha_echo error "ISSUE_URL format invalid for GitLab: '${ISSUE_URL:-}'" + exit 1 + fi + # shellcheck disable=SC2034 + REPO_ENCODED="$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri)" + # Derive GITLAB_HOST from ISSUE_URL first, then compare against any pre-set + # value. Using exit 1 (not post_fail_to_issue) avoids sending PRIVATE-TOKEN + # to the mismatched host. + _url_host="$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|')" + if [[ -n "${GITLAB_HOST:-}" && "${GITLAB_HOST}" != "${_url_host}" ]]; then + gha_echo error "GITLAB_HOST '${GITLAB_HOST}' does not match issue URL host '${_url_host}'" + exit 1 + fi + GITLAB_HOST="${_url_host}" +fi + # --------------------------------------------------------------------------- # Resolve target branch (ADR 0053) # @@ -258,7 +276,7 @@ if [[ -n "${AGENT_TARGET}" && ! "${AGENT_TARGET}" =~ ^[a-zA-Z0-9._/-]+$ ]]; then "Invalid branch name from agent output: '${AGENT_TARGET}'" fi -DEFAULT_BRANCH="$(GH_TOKEN="${PUSH_TOKEN}" gh api "repos/${REPO_FULL_NAME}" --jq '.default_branch' 2>/dev/null || echo 'main')" +DEFAULT_BRANCH="$(forge_get_default_branch "${PUSH_TOKEN}")" if [ -n "${AGENT_TARGET}" ]; then if [ -n "${CODE_ALLOWED_TARGET_BRANCHES:-}" ]; then @@ -303,7 +321,7 @@ post_noop_comment() { _post_failure_ensure_token local run_url - run_url="$(post_failure_workflow_run_url "${REPO_FULL_NAME}")" + run_url="$(forge_get_workflow_run_url)" # Try to extract agent reasoning from result file. # Note: RESULT_FILE is set at the top of the script and may point to a @@ -336,9 +354,7 @@ ${detail_block} Retry with \`/fs-code\` if appropriate." - if ! gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then + if ! forge_post_issue_comment "${body}"; then gha_echo warning "Failed to post no-op comment to issue #${safe_issue_number}" fi } @@ -359,7 +375,7 @@ fi # # The agent chooses its own branch name inside the sandbox. Rename it # deterministically using the trusted ISSUE_NUMBER (sourced from the -# GitHub event, not from agent output) so agent-authored pushes are +# CI event, not from agent output) so agent-authored pushes are # confined to this issue's namespace. # --------------------------------------------------------------------------- SAFE_BRANCH="$(enforce_branch_namespace "${BRANCH}" "${ISSUE_NUMBER}")" @@ -497,9 +513,10 @@ INSTALL_SCRIPT="${SCRIPT_DIR_POST}/install-precommit-tools.sh" # ${GITHUB_WORKSPACE}/scripts/ (per-org) or ${GITHUB_WORKSPACE}/.fullsend/scripts/ # (per-repo) — see fullsend-ai/.fullsend reusable workflows. Try those paths # when the BASH_SOURCE-relative lookup misses. +WORKSPACE_DIR="$(forge_get_workspace_dir)" if [ ! -f "${RESOLVE_SCRIPT}" ] || [ ! -f "${INSTALL_SCRIPT}" ]; then - if [ -n "${GITHUB_WORKSPACE:-}" ]; then - for _ws_candidate in "${GITHUB_WORKSPACE}/scripts" "${GITHUB_WORKSPACE}/.fullsend/scripts"; do + if [ -n "${WORKSPACE_DIR}" ]; then + for _ws_candidate in "${WORKSPACE_DIR}/scripts" "${WORKSPACE_DIR}/.fullsend/scripts"; do if [ -f "${_ws_candidate}/resolve-precommit-tools.py" ] \ && [ -f "${_ws_candidate}/install-precommit-tools.sh" ]; then RESOLVE_SCRIPT="${_ws_candidate}/resolve-precommit-tools.py" @@ -630,26 +647,28 @@ fi # --------------------------------------------------------------------------- # 6. Push branch # --------------------------------------------------------------------------- -git remote set-url origin \ - "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO_FULL_NAME}.git" +forge_set_push_remote "${PUSH_TOKEN}" -export GH_TOKEN="${PUSH_TOKEN}" +# Set token for forge CLI (GitHub uses GH_TOKEN, GitLab uses GITLAB_TOKEN) +if [ "${FULLSEND_FORGE}" = "github" ]; then + export GH_TOKEN="${PUSH_TOKEN}" +else + export GITLAB_TOKEN="${PUSH_TOKEN}" +fi # --------------------------------------------------------------------------- -# 7a. Delete stale remote branch if it exists with no open PR. +# 7a. Delete stale remote branch if it exists with no open PR/MR. # -# When a human closes a code agent PR and re-triggers /fs-code, the old +# When a human closes a code agent PR/MR and re-triggers /fs-code, the old # remote branch still exists. A plain push will fail with non-fast-forward # because the local branch was created fresh from origin/main. Delete the # stale remote branch so the push succeeds. # --------------------------------------------------------------------------- -REMOTE_REF_LINE="$(git ls-remote origin "refs/heads/${BRANCH}" 2>/dev/null | head -1 || true)" +REMOTE_REF_LINE="$(forge_check_remote_branch "${BRANCH}")" if [ -n "${REMOTE_REF_LINE}" ]; then echo "Remote branch ${BRANCH} already exists — checking for open PRs..." PR_LIST_RC=0 - OPEN_PR="$(gh pr list --repo "${REPO_FULL_NAME}" --head "${BRANCH}" \ - --state open --json number,headRepositoryOwner \ - --jq '[.[] | select(.headRepositoryOwner.login == "'"${REPO_FULL_NAME%%/*}"'")] | .[0].number // empty' 2>/dev/null)" || PR_LIST_RC=$? + OPEN_PR="$(forge_list_prs_for_branch "${BRANCH}")" || PR_LIST_RC=$? if [ "${PR_LIST_RC}" -ne 0 ]; then post_fail_to_issue api-error \ "Could not query open PRs for branch '${BRANCH}' — refusing to push." @@ -660,14 +679,12 @@ if [ -n "${REMOTE_REF_LINE}" ]; then "Branch '${BRANCH}' is outside agent/${ISSUE_NUMBER}-* namespace — refusing to delete." fi echo "No open PR uses ${BRANCH} — deleting stale remote branch" - git push origin --delete "${BRANCH}" 2>&1 || \ - gha_echo warning "Failed to delete stale remote branch ${BRANCH}" + forge_delete_remote_branch "${BRANCH}" else # Verify the open PR belongs to this issue. With deterministic branch # naming (agent/-*) this should always hold, but check # anyway as defense-in-depth against cross-issue commit injection. - PR_BODY_TEXT="$(gh pr view "${OPEN_PR}" --repo "${REPO_FULL_NAME}" \ - --json body --jq '.body' 2>/dev/null || true)" + PR_BODY_TEXT="$(forge_get_pr_details "${OPEN_PR}" "body" | jq -r '.body // .description // empty' 2>/dev/null || true)" PR_CLOSES_THIS_ISSUE=false if pr_body_refs_issue "${PR_BODY_TEXT}" "${ISSUE_NUMBER}"; then PR_CLOSES_THIS_ISSUE=true @@ -706,19 +723,16 @@ ${FORCE_PUSH_OUTPUT}" fi # --------------------------------------------------------------------------- -# 8. Create PR +# 8. Create PR/MR # --------------------------------------------------------------------------- -EXISTING_PR_NUM="$(gh pr list --repo "${REPO_FULL_NAME}" --head "${BRANCH}" \ - --state open --json number,headRepositoryOwner \ - --jq '[.[] | select(.headRepositoryOwner.login == "'"${REPO_FULL_NAME%%/*}"'")] | .[0].number // empty' 2>/dev/null || true)" +EXISTING_PR_NUM="$(forge_list_prs_for_branch "${BRANCH}")" || true if [ -n "${EXISTING_PR_NUM}" ]; then - EXISTING_PR_URL="$(gh pr view "${EXISTING_PR_NUM}" --repo "${REPO_FULL_NAME}" \ - --json url --jq '.url' 2>/dev/null || true)" + EXISTING_PR_URL="$(forge_get_pr_url "${EXISTING_PR_NUM}")" echo "PR #${EXISTING_PR_NUM} already exists — branch updated with new commits" echo "PR: ${EXISTING_PR_URL}" - echo "pr_url=${EXISTING_PR_URL}" >> "${GITHUB_OUTPUT:-/dev/null}" + forge_write_output "pr_url" "${EXISTING_PR_URL}" enable_auto_merge "${EXISTING_PR_NUM}" "${REPO_FULL_NAME}" existing maybe_assign_pr "${EXISTING_PR_NUM}" @@ -864,12 +878,11 @@ ${ISSUE_REF_KEYWORD} #${ISSUE_NUMBER} ${PR_BODY_SCAN_LINE}" PR_CREATE_STDERR=$(mktemp) -if ! PR_URL=$(gh pr create \ - --repo "${REPO_FULL_NAME}" \ - --head "${BRANCH}" \ - --base "${TARGET_BRANCH}" \ - --title "${PR_TITLE}" \ - --body "${PR_BODY}" 2>"${PR_CREATE_STDERR}"); then +if ! PR_URL=$(forge_create_pr \ + "${TARGET_BRANCH}" \ + "${BRANCH}" \ + "${PR_TITLE}" \ + "${PR_BODY}" 2>"${PR_CREATE_STDERR}"); then PR_CREATE_OUTPUT="$(cat "${PR_CREATE_STDERR}")" rm -f "${PR_CREATE_STDERR}" post_fail_to_issue pr-creation-failed "${PR_CREATE_OUTPUT}" @@ -877,18 +890,16 @@ fi rm -f "${PR_CREATE_STDERR}" echo "PR created: ${PR_URL}" -echo "pr_url=${PR_URL}" >> "${GITHUB_OUTPUT:-/dev/null}" +forge_write_output "pr_url" "${PR_URL}" # Apply ready-for-review label so the review agent is dispatched via the # issues.labeled path. pull_request_target.opened requires the PR author to # pass authorization checks that often exclude bot accounts; the label path # is used instead (label application requires repo write access). See # .github/scripts/check-e2e-authorization-test.sh for trusted-actor rules. +# Note: variable name is PR_NUMBER_FROM_URL (not PR_NUMBER) to avoid SC2153. PR_NUMBER_FROM_URL="${PR_URL##*/}" -gh issue edit "${PR_NUMBER_FROM_URL}" \ - --repo "${REPO_FULL_NAME}" \ - --add-label "ready-for-review" 2>/dev/null || \ - gha_echo warning "Failed to apply ready-for-review label to PR #${PR_NUMBER_FROM_URL}" +forge_add_label "ready-for-review" "pr" "${PR_NUMBER_FROM_URL}" # --------------------------------------------------------------------------- # 9. Auto-merge diff --git a/scripts/post-fix.sh b/scripts/post-fix.sh index 445a85e3..bdd48932 100755 --- a/scripts/post-fix.sh +++ b/scripts/post-fix.sh @@ -191,8 +191,10 @@ sanitize_failure_detail() { | sed -E \ -e 's/gh[pousr]_[A-Za-z0-9_]{20,}/[REDACTED]/g' \ -e 's/github_pat_[A-Za-z0-9_]+/[REDACTED]/g' \ + -e 's/glpat-[A-Za-z0-9_-]{20,}/[REDACTED]/g' \ -e 's/x-access-token:[^@[:space:]]+/x-access-token:[REDACTED]/g' \ - -e 's/(Bearer|token)[[:space:]]+[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ + -e 's/oauth2:[^@[:space:]]+/oauth2:[REDACTED]/g' \ + -e 's/(Bearer|token|PRIVATE-TOKEN:)[[:space:]]*[A-Za-z0-9._-]+/\1 [REDACTED]/gi' \ | _redact_multiline_pem)" if [ -n "${PUSH_TOKEN:-}" ]; then @@ -201,6 +203,9 @@ sanitize_failure_detail() { if [ -n "${GH_TOKEN:-}" ] && [ "${GH_TOKEN}" != "${PUSH_TOKEN:-}" ]; then detail="$(_redact_literal_token "${detail}" "${GH_TOKEN}")" fi + if [ -n "${GITLAB_TOKEN:-}" ] && [ "${GITLAB_TOKEN}" != "${PUSH_TOKEN:-}" ]; then + detail="$(_redact_literal_token "${detail}" "${GITLAB_TOKEN}")" + fi detail="$(sanitize_comment_workflow_commands "${detail}")" @@ -265,6 +270,10 @@ EOF post_failure_workflow_run_url() { local repo_full_name="$1" + if declare -F forge_get_workflow_run_url >/dev/null 2>&1; then + forge_get_workflow_run_url + return 0 + fi local run_repo="${GITHUB_REPOSITORY:-${repo_full_name}}" printf '%s/%s/actions/runs/%s' \ "${GITHUB_SERVER_URL:-https://github.com}" \ @@ -322,8 +331,14 @@ EOF } _post_failure_ensure_token() { - if [ -z "${GH_TOKEN:-}" ]; then - export GH_TOKEN="${PUSH_TOKEN:-}" + if [ "${FULLSEND_FORGE:-}" = "gitlab" ]; then + if [ -z "${GITLAB_TOKEN:-}" ]; then + export GITLAB_TOKEN="${PUSH_TOKEN:-}" + fi + else + if [ -z "${GH_TOKEN:-}" ]; then + export GH_TOKEN="${PUSH_TOKEN:-}" + fi fi } @@ -350,10 +365,16 @@ report_post_failure_to_issue() { "${REPO_FULL_NAME}" "/fs-code")" gha_echo warning "Posting failure comment to issue #${safe_issue_number}..." - if ! gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + if declare -F forge_post_issue_comment >/dev/null 2>&1; then + if ! forge_post_issue_comment "${body}"; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number}" + fi + else + if ! gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to issue #${safe_issue_number} (check issues:write on PUSH_TOKEN)" + fi fi } @@ -379,10 +400,16 @@ report_post_failure_to_pr() { "${REPO_FULL_NAME}" "/fs-fix")" gha_echo warning "Posting failure comment to PR #${safe_pr_number}..." - if ! gh pr comment "${PR_NUMBER}" \ - --repo "${REPO_FULL_NAME}" \ - --body "${body}" 2>/dev/null; then - gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + if declare -F forge_post_pr_comment >/dev/null 2>&1; then + if ! forge_post_pr_comment "${PR_NUMBER}" "${body}"; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number}" + fi + else + if ! gh pr comment "${PR_NUMBER}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null; then + gha_echo warning "Failed to post error comment to PR #${safe_pr_number} (check pull-requests:write on PUSH_TOKEN)" + fi fi } diff --git a/scripts/pre-code-test.sh b/scripts/pre-code-test.sh index 82328c3a..1949e276 100644 --- a/scripts/pre-code-test.sh +++ b/scripts/pre-code-test.sh @@ -106,6 +106,8 @@ run_test() { ISSUE_NUMBER="42" REPO_FULL_NAME="test-org/test-repo" GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + FULLSEND_FORGE="github" GH_TOKEN="fake-token" GITHUB_OUTPUT="${gh_output}" ) @@ -171,6 +173,8 @@ run_test_stdout() { ISSUE_NUMBER="42" REPO_FULL_NAME="test-org/test-repo" GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + FULLSEND_FORGE="github" GH_TOKEN="fake-token" GITHUB_OUTPUT="${gh_output}" ) @@ -232,6 +236,8 @@ run_test_stdout_excludes() { ISSUE_NUMBER="42" REPO_FULL_NAME="test-org/test-repo" GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + FULLSEND_FORGE="github" GH_TOKEN="fake-token" GITHUB_OUTPUT="${gh_output}" ) @@ -347,7 +353,7 @@ run_test_stdout "force-override-comment-body" \ # No GH_TOKEN → skips check entirely, exits 0. run_test_stdout "no-gh-token-skips-check" \ "" \ - "GH_TOKEN not set" \ + "No github token set" \ 0 \ "GH_TOKEN=" @@ -472,6 +478,8 @@ run_test_prescript_output() { ISSUE_NUMBER="42" REPO_FULL_NAME="test-org/test-repo" GITHUB_ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + ISSUE_URL="https://github.com/test-org/test-repo/issues/42" + FULLSEND_FORGE="github" GH_TOKEN="fake-token" GITHUB_OUTPUT="${gh_output}" FULLSEND_PRESCRIPT_OUTPUT="${proto_out}" diff --git a/scripts/pre-code.sh b/scripts/pre-code.sh index 5807b940..5dddeba9 100755 --- a/scripts/pre-code.sh +++ b/scripts/pre-code.sh @@ -16,7 +16,8 @@ # Required environment variables (set by the workflow): # ISSUE_NUMBER — must be a positive integer # REPO_FULL_NAME — must be owner/repo format -# GITHUB_ISSUE_URL — must be a valid GitHub issue URL +# ISSUE_URL — must be a valid issue URL for the forge +# FULLSEND_FORGE — "github" or "gitlab" set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -48,8 +49,729 @@ prescript_output() { fi } # END bundled: lib/prescript-output.lib.sh +# shellcheck source=lib/code-ops.lib.sh +# BEGIN bundled: lib/code-ops.lib.sh +# shellcheck shell=bash +# code-ops.lib.sh — Forge-dispatch wrapper for code agent operations. +# +# Sources the correct forge-specific ops based on FULLSEND_FORGE. +# Bundled inline by bundle-sh.sh at build time. + +[[ -n "${CODE_OPS_SH_LOADED:-}" ]] && return 0 +CODE_OPS_SH_LOADED=1 + +case "${FULLSEND_FORGE:-}" in + github) +# BEGIN bundled: lib/github-code-ops.lib.sh +# shellcheck shell=bash +# github-code-ops.lib.sh — GitHub forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use the gh CLI and the GitHub REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — owner/repo (e.g., "org/repo") +# ISSUE_NUMBER — issue number +# +# Expected env vars: +# GH_TOKEN — GitHub token with appropriate scopes + +[[ -n "${GITHUB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITHUB_CODE_OPS_SH_LOADED=1 + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://github\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitHub pattern: ${url}" >&2 + return 1 + fi +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + REPO_FULL_NAME=$(echo "${url}" | sed 's|https://github.com/||; s|/issues/.*||') + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|https://github.com/([^/]+/[^/]+)/issues/.*|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + gh issue edit "${number}" --repo "${REPO_FULL_NAME}" \ + --add-label "${label}" 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to PR #${number}" + else + gh api "repos/${REPO_FULL_NAME}/issues/${number}/labels" \ + -f "labels[]=${label}" --silent 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + gh label create "${name}" --repo "${REPO_FULL_NAME}" \ + --description "${description}" --color "${color}" \ + --force 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + printf '%s' "${body}" | gh issue comment "${ISSUE_NUMBER}" \ + --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null +} + +forge_post_pr_comment() { + local target_pr="$1" + local body="$2" + gh pr comment "${target_pr}" \ + --repo "${REPO_FULL_NAME}" \ + --body "${body}" 2>/dev/null +} + +# --- PR/MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-fullsend-ai[bot]}" + local coder_bot_login="${3:-fullsend-ai-coder[bot]}" + gh pr list --repo "${REPO_FULL_NAME}" --state open \ + --search "${search_term} in:body,title" \ + --json number,url,author \ + --jq "[.[] | select(.author.login != \"${bot_login}\" and .author.login != \"${coder_bot_login}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ + 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + gh pr list --repo "${REPO_FULL_NAME}" --head "${branch}" \ + --state open --json number,headRepositoryOwner \ + --jq "[.[] | select(.headRepositoryOwner.login == \"${owner}\")] | .[0].number // empty" \ + 2>/dev/null +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + gh pr create \ + --repo "${REPO_FULL_NAME}" \ + --head "${head}" \ + --base "${base}" \ + --title "${title}" \ + --body "${body}" +} + +forge_get_pr_url() { + local target_pr="$1" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json url --jq '.url' 2>/dev/null || true +} + +forge_get_pr_details() { + local target_pr="$1" + local fields="$2" + gh pr view "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --json "${fields}" 2>/dev/null +} + +forge_assign_pr() { + local target_pr="$1" + local assignee="$2" + local assign_err + assign_err="$(gh pr edit "${target_pr}" --repo "${REPO_FULL_NAME}" \ + --add-assignee "${assignee}" 2>&1)" || { + _pr_assignee_warn "Failed to assign PR #${target_pr} to ${assignee} — continuing" + if [[ -n "${assign_err}" ]]; then + _pr_assignee_warn "${assign_err}" + fi + } +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${PUSH_TOKEN:-}}" + GH_TOKEN="${token}" gh api "repos/${REPO_FULL_NAME}" --jq '.default_branch' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://x-access-token:${token}@github.com/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the x-access-token:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Merge queue / auto-merge --- + +forge_check_merge_queue() { + local base_branch="$1" + local owner="${REPO_FULL_NAME%%/*}" + local name="${REPO_FULL_NAME##*/}" + gh api graphql -f query=" + query { repository(owner: \"${owner}\", name: \"${name}\") { + mergeQueue(branch: \"${base_branch}\") { id } + }}" --jq '.data.repository.mergeQueue.id // empty' 2>/dev/null || true +} + +forge_get_repo_merge_methods() { + gh api "repos/${REPO_FULL_NAME}" \ + --jq '{s:.allow_squash_merge,m:.allow_merge_commit,r:.allow_rebase_merge}' 2>/dev/null || true +} + +forge_enable_auto_merge() { + local target_pr="$1" + local method_flag="$2" + local merge_output + # shellcheck disable=SC2086 + if ! merge_output="$(gh pr merge "${target_pr}" --auto ${method_flag} \ + --repo "${REPO_FULL_NAME}" 2>&1)"; then + print_sanitized_gha_log "${merge_output}" + gha_echo warning "Failed to enable auto-merge on PR #${target_pr} — continuing" + else + print_sanitized_gha_log "${merge_output}" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local raw + if ! raw="$(gh api --paginate \ + "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/comments" 2>/dev/null)"; then + echo '[]' + return 0 + fi + if [[ -z "${raw}" ]]; then + echo '[]' + return 0 + fi + echo "${raw}" | jq -s 'add // []' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + gh issue view "${ISSUE_NUMBER}" --repo "${REPO_FULL_NAME}" \ + --json assignees,author 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local run_repo="${GITHUB_REPOSITORY:-${REPO_FULL_NAME}}" + printf '%s/%s/actions/runs/%s' \ + "${GITHUB_SERVER_URL:-https://github.com}" \ + "${run_repo}" \ + "${GITHUB_RUN_ID:-unknown}" +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + echo "${key}=${value}" >> "${GITHUB_OUTPUT:-/dev/null}" +} -echo "::notice::🔗 Code target: ${GITHUB_ISSUE_URL:-}" +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${GITHUB_WORKSPACE:-}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${GITHUB_WORKSPACE:-}/target-repo}" +} + +forge_append_path() { + local dir="$1" + echo "${dir}" >> "${GITHUB_PATH:-/dev/null}" +} +# END bundled: lib/github-code-ops.lib.sh + ;; + gitlab) +# BEGIN bundled: lib/gitlab-code-ops.lib.sh +# shellcheck shell=bash +# gitlab-code-ops.lib.sh — GitLab forge operations for code agent scripts. +# +# Bundled into pre-code.sh and post-code.sh via code-ops.lib.sh. +# All functions use curl against the GitLab REST API. +# +# Expected globals (set by caller or forge_parse_issue_url): +# REPO_FULL_NAME — plain project path (e.g., "group/project") +# REPO_ENCODED — URL-encoded project path (e.g., "group%2Fproject") +# ISSUE_NUMBER — issue IID +# GITLAB_HOST — API host (e.g., "gitlab.com") +# +# Expected env vars: +# ISSUE_URL — HTML URL of the issue +# GITLAB_TOKEN — GitLab personal/project access token +# +# Token scopes: GITLAB_TOKEN requires minimum scopes: +# - api (read/write issues, labels, notes, merge requests) + +[[ -n "${GITLAB_CODE_OPS_SH_LOADED:-}" ]] && return 0 +GITLAB_CODE_OPS_SH_LOADED=1 + +_gitlab_code_api() { + local method="$1" + shift + local endpoint="$1" + shift + curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" +} + +_gitlab_code_api_with_status() { + local method="$1" + shift + local endpoint="$1" + shift + local err_file + err_file=$(mktemp) + local raw + raw=$(curl --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + --header "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \ + --request "${method}" \ + --write-out '\n%{http_code}' \ + "https://${GITLAB_HOST}/api/v4${endpoint}" \ + "$@" 2>"${err_file}") || { + echo "GitLab API error: curl failed — $(cat "${err_file}")" >&2 + rm -f "${err_file}" + return 1 + } + rm -f "${err_file}" + local http_code + http_code=$(echo "${raw}" | tail -1) + local body + body=$(echo "${raw}" | sed '$d') + if [[ "${http_code}" -lt 200 || "${http_code}" -ge 300 ]]; then + local _truncated + _truncated=$(printf '%.200s' "${body}") + echo "GitLab API error (HTTP ${http_code}): ${_truncated}" >&2 + return 1 + fi + echo "${body}" +} + +# --- URL handling --- + +forge_validate_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + if [[ ! "${url}" =~ ^https://[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+/-/issues/[0-9]+$ ]]; then + echo "ERROR: ISSUE_URL does not match expected GitLab pattern: ${url}" >&2 + return 1 + fi + local host + host=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + # Allowed GitLab hosts. To support a self-hosted instance, add it here + # AND in the network policy (policies/gitlab/code.yaml). + case "${host}" in + gitlab.com|gitlab.cee.redhat.com) ;; + *) echo "ERROR: GitLab host '${host}' is not in the allowed host list (see gitlab-code-ops.lib.sh and policies/gitlab/code.yaml)" >&2; return 1 ;; + esac +} + +forge_parse_issue_url() { + local url="${1:-${ISSUE_URL:-}}" + GITLAB_HOST=$(echo "${url}" | sed -E 's|^https://([^/]+)/.*|\1|') + REPO_FULL_NAME=$(echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|') + REPO_ENCODED=$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri) + ISSUE_NUMBER=$(basename "${url}") +} + +forge_extract_repo_from_url() { + local url="$1" + echo "${url}" | sed -E 's|^https://[^/]+/(.+)/-/issues/[0-9]+$|\1|' +} + +forge_extract_issue_from_url() { + local url="$1" + echo "${url}" | sed -E 's|.*/issues/([0-9]+)$|\1|' +} + +# --- Label operations --- + +forge_add_label() { + local label="$1" + local target="${2:-issue}" + local number="${3:-${ISSUE_NUMBER}}" + if [ "${target}" = "pr" ]; then + # On GitLab, MRs use the same label update mechanism + local mr_iid="${number}" + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || \ + gha_echo warning "Failed to apply ${label} label to MR !${mr_iid}" + else + _gitlab_code_api PUT "/projects/${REPO_ENCODED}/issues/${number}" \ + --data-urlencode "add_labels=${label}" > /dev/null 2>/dev/null || true + fi +} + +forge_create_label() { + local name="$1" + local description="$2" + local color="$3" + # GitLab requires # prefix on colors + _gitlab_code_api POST "/projects/${REPO_ENCODED}/labels" \ + --data-urlencode "name=${name}" \ + --data-urlencode "description=${description}" \ + --data-urlencode "color=#${color}" > /dev/null 2>/dev/null || true +} + +# --- Comment operations --- + +forge_post_issue_comment() { + local body="$1" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +forge_post_pr_comment() { + local mr_iid="$1" + local body="$2" + _gitlab_code_api POST "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/notes" \ + --data-urlencode "body=${body}" > /dev/null 2>/dev/null +} + +# --- MR lifecycle --- + +forge_list_prs_for_issue() { + local search_term="$1" + local bot_login="${2:-}" + local coder_bot_login="${3:-}" + # GitLab API: search MRs referencing the issue. Best-effort — GitLab does not + # have a direct "MRs linked to issue" search like GitHub's "in:body,title". + # Search open MRs and filter by body/title containing # with word + # boundaries to avoid false positives (e.g., #42 must not match #142 or #420). + local all_mrs="[]" + local page=1 max_pages=10 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&per_page=100&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length' 2>/dev/null) || break + [[ "${count}" -eq 0 ]] && break + all_mrs=$(echo "${all_mrs}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Filter for MRs mentioning # (anchored), exclude known bot authors + # and MRs whose source_branch matches agent/-* (code agent branches). + echo "${all_mrs}" | jq -r --arg term "${search_term}" \ + --arg bot1 "${bot_login}" --arg bot2 "${coder_bot_login}" ' + [.[] | select( + ((.title // "") | test("(^|\\W)#" + $term + "($|\\W)")) or + ((.description // "") | test("(^|\\W)#" + $term + "($|\\W)")) + ) | select( + ((.source_branch // "") | test("^agent/" + $term + "-") | not) + ) | select( + (if $bot1 != "" then (.author.username // "") != $bot1 else true end) and + (if $bot2 != "" then (.author.username // "") != $bot2 else true end) and + (.author.username // "" | test("\\[bot\\]$") | not) and + (.author.username // "" | test("^fullsend") | not) + )] | .[] | "\(.iid)\t\(.author.username)\t\(.web_url)" + ' 2>/dev/null || true +} + +forge_list_prs_for_branch() { + local branch="$1" + local branch_encoded + branch_encoded=$(printf '%s' "${branch}" | jq -sRr @uri) + local mrs + mrs=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests?state=opened&source_branch=${branch_encoded}" 2>/dev/null) || return 1 + # Filter to same-project MRs only (exclude fork MRs) — mirrors the GitHub + # implementation which filters by headRepositoryOwner. + local project_id + project_id=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null | jq -r '.id // empty') || true + if [[ -z "${project_id}" ]]; then + gha_echo warning "Could not resolve project ID for fork-MR filtering — failing closed" + return 1 + fi + echo "${mrs}" | jq -r --arg pid "${project_id}" \ + '[.[] | select(.source_project_id == ($pid | tonumber))] | .[0].iid // empty' +} + +forge_create_pr() { + local base="$1" + local head="$2" + local title="$3" + local body="$4" + local response + response=$(_gitlab_code_api_with_status POST "/projects/${REPO_ENCODED}/merge_requests" \ + --data-urlencode "source_branch=${head}" \ + --data-urlencode "target_branch=${base}" \ + --data-urlencode "title=${title}" \ + --data-urlencode "description=${body}") || return 1 + echo "${response}" | jq -r '.web_url' +} + +forge_get_pr_url() { + local mr_iid="$1" + local mr_json + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + echo "" + return 0 + } + echo "${mr_json}" | jq -r '.web_url // empty' 2>/dev/null || true +} + +forge_get_pr_details() { + local mr_iid="$1" + local _fields="$2" # accepted for interface parity but GitLab returns all fields + _gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null +} + +forge_assign_pr() { + local mr_iid="$1" + local assignee="$2" + # Resolve assignee username to user ID for GitLab + local user_json user_id + local assignee_encoded + assignee_encoded=$(printf '%s' "${assignee}" | jq -sRr @uri) + user_json=$(_gitlab_code_api GET "/users?username=${assignee_encoded}" 2>/dev/null) || { + _pr_assignee_warn "Failed to resolve GitLab user '${assignee}' — skipping assignment" + return 0 + } + user_id=$(echo "${user_json}" | jq -r '.[0].id // empty' 2>/dev/null) + if [[ -z "${user_id}" ]]; then + _pr_assignee_warn "GitLab user '${assignee}' not found — skipping assignment" + return 0 + fi + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" \ + --data-urlencode "assignee_ids[]=${user_id}" > /dev/null 2>/dev/null; then + _pr_assignee_warn "Failed to assign MR !${mr_iid} to ${assignee} — continuing" + fi +} + +# --- Repository operations --- + +forge_get_default_branch() { + local token="${1:-${GITLAB_TOKEN:-}}" + local project_json + project_json=$(GITLAB_TOKEN="${token}" _gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo 'main' + return 0 + } + echo "${project_json}" | jq -r '.default_branch // "main"' 2>/dev/null || echo 'main' +} + +forge_set_push_remote() { + local token="$1" + git remote set-url origin \ + "https://oauth2:${token}@${GITLAB_HOST}/${REPO_FULL_NAME}.git" +} + +forge_check_remote_branch() { + local branch="$1" + git ls-remote origin "refs/heads/${branch}" 2>/dev/null | head -1 || true +} + +forge_delete_remote_branch() { + local branch="$1" + local _del_output + _del_output="$(git push origin --delete "${branch}" 2>&1)" || { + # Sanitize before logging — git may echo the oauth2:@ remote URL. + if declare -F print_sanitized_gha_log >/dev/null 2>&1; then + print_sanitized_gha_log "${_del_output}" + fi + gha_echo warning "Failed to delete stale remote branch ${branch}" + } +} + +# --- Auto-merge --- + +forge_check_merge_queue() { + # GitLab does not have a merge queue equivalent; merge trains are configured + # per-project but have no API query like GitHub's mergeQueue. + echo "" +} + +forge_get_repo_merge_methods() { + local project_json + project_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}" 2>/dev/null) || { + echo "" + return 0 + } + local method + method=$(echo "${project_json}" | jq -r '.merge_method // "merge"' 2>/dev/null) + # Map GitLab merge_method to the same JSON shape as GitHub for compat + case "${method}" in + merge) echo '{"s":false,"m":true,"r":false}' ;; + rebase_merge) echo '{"s":false,"m":false,"r":true}' ;; + ff) echo '{"s":false,"m":false,"r":true}' ;; + *) echo '{"s":false,"m":true,"r":false}' ;; + esac +} + +forge_enable_auto_merge() { + local mr_iid="$1" + local _method_flag="$2" # accepted for interface parity, GitLab uses merge_when_pipeline_succeeds + + # Safety guard: merge_when_pipeline_succeeds merges immediately when the + # pipeline has already passed or no pipeline exists. Match the GitHub path's + # BLOCKED-state guard by requiring a running pipeline before arming. + # Retry up to 3 times (like GitHub) — new MRs may report "none" briefly. + local mr_json pipeline_status _am_attempt + for _am_attempt in 1 2 3; do + mr_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}" 2>/dev/null) || { + gha_echo warning "Auto-merge: could not query MR !${mr_iid} — skipping" + return 0 + } + pipeline_status=$(echo "${mr_json}" | jq -r '.head_pipeline.status // "none"') + + case "${pipeline_status}" in + running|pending|created) + break + ;; + none) + if [ "${_am_attempt}" -lt 3 ]; then + echo "Auto-merge: MR !${mr_iid} pipeline status is 'none' (attempt ${_am_attempt}/3) — retrying in 5s..." + sleep 5 + continue + fi + gha_echo warning "Auto-merge: MR !${mr_iid} has no pipeline after 3 attempts — skipping (would merge immediately)" + return 0 + ;; + success) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline already passed — skipping (would merge immediately)" + return 0 + ;; + *) + gha_echo warning "Auto-merge: MR !${mr_iid} pipeline status '${pipeline_status}' — skipping" + return 0 + ;; + esac + done + + if ! _gitlab_code_api PUT "/projects/${REPO_ENCODED}/merge_requests/${mr_iid}/merge" \ + --data-urlencode "merge_when_pipeline_succeeds=true" > /dev/null 2>/dev/null; then + gha_echo warning "Failed to enable auto-merge on MR !${mr_iid} — continuing" + fi +} + +# --- Issue operations --- + +forge_get_issue_comments() { + local notes="[]" + local page=1 max_pages=50 + while [[ "${page}" -le "${max_pages}" ]]; do + local batch + batch=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}/notes?per_page=100&sort=asc&page=${page}" 2>/dev/null) || break + local count + count=$(echo "${batch}" | jq 'length') || break + [[ "${count}" -eq 0 ]] && break + notes=$(echo "${notes}" "${batch}" | jq -s 'add') || break + page=$((page + 1)) + done + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${notes}" | jq '[.[] | {user: {login: .author.username}, body: .body}]' 2>/dev/null || echo '[]' +} + +forge_get_issue_details() { + local issue_json + issue_json=$(_gitlab_code_api GET "/projects/${REPO_ENCODED}/issues/${ISSUE_NUMBER}" 2>/dev/null) || { + echo "" + return 0 + } + # Remap GitLab shape to match GitHub expected shape for pr-assignee.lib.sh + echo "${issue_json}" | jq '{ + assignees: [(.assignees // [])[] | {login: .username}], + author: {login: (.author.username // "")} + }' 2>/dev/null || true +} + +# --- CI operations --- + +forge_get_workflow_run_url() { + local server_url="${CI_SERVER_URL:-https://gitlab.com}" + local project_path="${CI_PROJECT_PATH:-${REPO_FULL_NAME}}" + local pipeline_id="${CI_PIPELINE_ID:-unknown}" + local job_id="${CI_JOB_ID:-}" + if [[ -n "${job_id}" ]]; then + printf '%s/%s/-/jobs/%s' "${server_url}" "${project_path}" "${job_id}" + else + printf '%s/%s/-/pipelines/%s' "${server_url}" "${project_path}" "${pipeline_id}" + fi +} + +# --- Output operations --- + +forge_write_output() { + local key="$1" + local value="$2" + # GitLab CI uses artifacts or dotenv for output; write to GITHUB_OUTPUT + # if available (hybrid compatibility), otherwise no-op. + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "${key}=${value}" >> "${GITHUB_OUTPUT}" + fi +} + +# --- Workspace operations --- + +forge_get_workspace_dir() { + echo "${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}}" +} + +forge_get_repo_dir() { + echo "${REPO_DIR:-${CI_PROJECT_DIR:-${GITHUB_WORKSPACE:-}/target-repo}}" +} + +forge_append_path() { + local dir="$1" + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "${dir}" >> "${GITHUB_PATH}" + fi + # On GitLab CI, PATH is modified directly (already done by caller) +} +# END bundled: lib/gitlab-code-ops.lib.sh + ;; + *) + echo "ERROR: invalid FULLSEND_FORGE: '${FULLSEND_FORGE:-}' — pass --forge or set FULLSEND_FORGE" >&2 + exit 1 + ;; +esac +# END bundled: lib/code-ops.lib.sh + +echo "::notice::🔗 Code target: ${ISSUE_URL:-}" errors=0 @@ -58,18 +780,18 @@ if [[ ! "${ISSUE_NUMBER:-}" =~ ^[1-9][0-9]*$ ]]; then errors=$((errors + 1)) fi -if [[ ! "${REPO_FULL_NAME:-}" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then +if [[ ! "${REPO_FULL_NAME:-}" =~ ^[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+$ ]]; then echo "::error::REPO_FULL_NAME must be owner/repo format, got: '${REPO_FULL_NAME:-}'" errors=$((errors + 1)) fi -if [[ ! "${GITHUB_ISSUE_URL:-}" =~ ^https://github\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/issues/[0-9]+$ ]]; then - echo "::error::GITHUB_ISSUE_URL format invalid, got: '${GITHUB_ISSUE_URL:-}'" +if ! forge_validate_issue_url "${ISSUE_URL:-}"; then + echo "::error::ISSUE_URL format invalid, got: '${ISSUE_URL:-}'" errors=$((errors + 1)) fi -URL_REPO="$(echo "${GITHUB_ISSUE_URL:-}" | sed -E 's|https://github.com/([^/]+/[^/]+)/issues/.*|\1|')" -URL_ISSUE="$(echo "${GITHUB_ISSUE_URL:-}" | sed -E 's|.*/issues/([0-9]+)$|\1|')" +URL_REPO="$(forge_extract_repo_from_url "${ISSUE_URL:-}" 2>/dev/null || true)" +URL_ISSUE="$(forge_extract_issue_from_url "${ISSUE_URL:-}" 2>/dev/null || true)" if [[ -n "${URL_REPO}" && "${URL_REPO}" != "${REPO_FULL_NAME:-}" ]]; then echo "::error::REPO_FULL_NAME does not match issue URL repo ('${REPO_FULL_NAME:-}' vs '${URL_REPO}')" @@ -88,14 +810,34 @@ fi echo "Input validation passed:" echo " ISSUE_NUMBER=${ISSUE_NUMBER}" echo " REPO_FULL_NAME=${REPO_FULL_NAME}" -echo " GITHUB_ISSUE_URL=${GITHUB_ISSUE_URL}" +echo " ISSUE_URL=${ISSUE_URL}" + +# GitLab needs REPO_ENCODED and GITLAB_HOST for API calls — set them before +# any forge function that hits the API (forge_list_prs_for_issue, labels, etc.). +# Always derive GITLAB_HOST from the validated ISSUE_URL. If GITLAB_HOST is +# pre-set in the environment, verify it matches the URL host to prevent +# token exfiltration to an unintended host. +if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + # shellcheck disable=SC2034 + REPO_ENCODED="$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri)" + if [[ -n "${ISSUE_URL:-}" ]]; then + _url_host="$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|')" + if [[ -n "${GITLAB_HOST:-}" && "${GITLAB_HOST}" != "${_url_host}" ]]; then + echo "::error::GITLAB_HOST '${GITLAB_HOST}' does not match issue URL host '${_url_host}'" + exit 1 + fi + GITLAB_HOST="${_url_host}" + fi + GITLAB_HOST="${GITLAB_HOST:-gitlab.com}" +fi # --------------------------------------------------------------------------- # Check for existing human PRs linked to this issue # --------------------------------------------------------------------------- -# Skip if GH_TOKEN is not available (best-effort check). -if [[ -z "${GH_TOKEN:-}" ]]; then - echo "GH_TOKEN not set — skipping existing-PR check" +# Skip if the forge-specific token is not available (best-effort check). +if { [ "${FULLSEND_FORGE}" = "github" ] && [ -z "${GH_TOKEN:-}" ]; } || \ + { [ "${FULLSEND_FORGE}" = "gitlab" ] && [ -z "${GITLAB_TOKEN:-}" ]; }; then + echo "No ${FULLSEND_FORGE} token set — skipping existing-PR check" exit 0 fi @@ -119,34 +861,30 @@ CODER_BOT_LOGIN="fullsend-ai-coder[bot]" echo "Checking for existing open PRs linked to issue #${ISSUE_NUMBER}..." -# Search for open PRs in the repo that mention the issue number. -# This catches PRs with "Closes #N", "Fixes #N", or "#N" in the body/title. -# Use gh's built-in --jq to filter out bot-authored PRs in one call. -HUMAN_PR_LINES="$(gh pr list --repo "${REPO_FULL_NAME}" --state open \ - --search "${ISSUE_NUMBER} in:body,title" \ - --json number,url,author \ - --jq "[.[] | select(.author.login != \"${BOT_LOGIN}\" and .author.login != \"${CODER_BOT_LOGIN}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ - 2>/dev/null || true)" +HUMAN_PR_LINES="$(forge_list_prs_for_issue "${ISSUE_NUMBER}" "${BOT_LOGIN}" "${CODER_BOT_LOGIN}")" if [[ -n "${HUMAN_PR_LINES}" ]]; then # Parse the first PR for the notice. FIRST_PR_NUM="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f1)" FIRST_PR_AUTHOR="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f2)" - echo "::notice::Found existing human PR #${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR}" + # GitLab uses ! for MR references; GitHub uses #. + _pr_prefix="#" + if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + _pr_prefix="!" + fi + + echo "::notice::Found existing human PR ${_pr_prefix}${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR}" # Apply pr-open label to signal work is already underway. - gh label create "pr-open" --repo "${REPO_FULL_NAME}" \ - --description "An open PR already addresses this issue" --color "D4C5F9" \ - --force 2>/dev/null || true - gh api "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/labels" \ - -f "labels[]=pr-open" --silent 2>/dev/null || true + forge_create_label "pr-open" "An open PR already addresses this issue" "D4C5F9" + forge_add_label "pr-open" # Build a markdown list of existing PRs. PR_LIST_MD="" while IFS=$'\t' read -r pr_num pr_author _pr_url; do PR_LIST_MD="${PR_LIST_MD} -- #${pr_num} by @${pr_author}" +- ${_pr_prefix}${pr_num} by @${pr_author}" done <<< "${HUMAN_PR_LINES}" SKIP_COMMENT="An open PR already addresses this issue — skipping automated implementation. @@ -156,12 +894,11 @@ To override, comment \`/fs-code --force\` on this issue. Posted by fullsend pre-code check" - printf '%s' "${SKIP_COMMENT}" | gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null || true + forge_post_issue_comment "${SKIP_COMMENT}" || true echo "Skipping code agent — existing PR(s) found for issue #${ISSUE_NUMBER}" prescript_output "skipped" "true" - prescript_output "reason" "open PR #${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR} already addresses issue #${ISSUE_NUMBER}" + prescript_output "reason" "open PR ${_pr_prefix}${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR} already addresses issue #${ISSUE_NUMBER}" exit 0 fi @@ -170,7 +907,7 @@ echo "No existing human PRs found — proceeding with code agent" # --------------------------------------------------------------------------- # Auto-detect and install pre-commit tool dependencies # --------------------------------------------------------------------------- -TARGET_REPO="${REPO_DIR:-${GITHUB_WORKSPACE:-}/target-repo}" +TARGET_REPO="$(forge_get_repo_dir)" RESOLVE_SCRIPT="${SCRIPT_DIR}/resolve-precommit-tools.py" INSTALL_SCRIPT="${SCRIPT_DIR}/install-precommit-tools.sh" @@ -180,9 +917,10 @@ INSTALL_SCRIPT="${SCRIPT_DIR}/install-precommit-tools.sh" # materializes the full scripts/ directory (from fullsend's own scaffold) # at ${GITHUB_WORKSPACE}/scripts/ (per-org) or ${GITHUB_WORKSPACE}/.fullsend/scripts/ # (per-repo). Try those paths when the BASH_SOURCE-relative lookup misses. +WORKSPACE_DIR="$(forge_get_workspace_dir)" if [ ! -f "${RESOLVE_SCRIPT}" ] || [ ! -f "${INSTALL_SCRIPT}" ]; then - if [ -n "${GITHUB_WORKSPACE:-}" ]; then - for _ws_candidate in "${GITHUB_WORKSPACE}/scripts" "${GITHUB_WORKSPACE}/.fullsend/scripts"; do + if [ -n "${WORKSPACE_DIR}" ]; then + for _ws_candidate in "${WORKSPACE_DIR}/scripts" "${WORKSPACE_DIR}/.fullsend/scripts"; do if [ -f "${_ws_candidate}/resolve-precommit-tools.py" ] \ && [ -f "${_ws_candidate}/install-precommit-tools.sh" ]; then RESOLVE_SCRIPT="${_ws_candidate}/resolve-precommit-tools.py" @@ -227,4 +965,4 @@ if [ -f "${TARGET_REPO}/.pre-commit-config.yaml" ] \ rm -f "${MANIFEST}" "${LOCAL_REG}" fi export PATH="${HOME}/.local/bin:${PATH}" -echo "${HOME}/.local/bin" >> "${GITHUB_PATH:-/dev/null}" +forge_append_path "${HOME}/.local/bin" diff --git a/scripts/pre-code.src.sh b/scripts/pre-code.src.sh index f600efe8..f7f19bf8 100755 --- a/scripts/pre-code.src.sh +++ b/scripts/pre-code.src.sh @@ -15,14 +15,17 @@ # Required environment variables (set by the workflow): # ISSUE_NUMBER — must be a positive integer # REPO_FULL_NAME — must be owner/repo format -# GITHUB_ISSUE_URL — must be a valid GitHub issue URL +# ISSUE_URL — must be a valid issue URL for the forge +# FULLSEND_FORGE — "github" or "gitlab" set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/prescript-output.lib.sh source "${SCRIPT_DIR}/lib/prescript-output.lib.sh" +# shellcheck source=lib/code-ops.lib.sh +source "${SCRIPT_DIR}/lib/code-ops.lib.sh" -echo "::notice::🔗 Code target: ${GITHUB_ISSUE_URL:-}" +echo "::notice::🔗 Code target: ${ISSUE_URL:-}" errors=0 @@ -31,18 +34,18 @@ if [[ ! "${ISSUE_NUMBER:-}" =~ ^[1-9][0-9]*$ ]]; then errors=$((errors + 1)) fi -if [[ ! "${REPO_FULL_NAME:-}" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]]; then +if [[ ! "${REPO_FULL_NAME:-}" =~ ^[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+$ ]]; then echo "::error::REPO_FULL_NAME must be owner/repo format, got: '${REPO_FULL_NAME:-}'" errors=$((errors + 1)) fi -if [[ ! "${GITHUB_ISSUE_URL:-}" =~ ^https://github\.com/[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+/issues/[0-9]+$ ]]; then - echo "::error::GITHUB_ISSUE_URL format invalid, got: '${GITHUB_ISSUE_URL:-}'" +if ! forge_validate_issue_url "${ISSUE_URL:-}"; then + echo "::error::ISSUE_URL format invalid, got: '${ISSUE_URL:-}'" errors=$((errors + 1)) fi -URL_REPO="$(echo "${GITHUB_ISSUE_URL:-}" | sed -E 's|https://github.com/([^/]+/[^/]+)/issues/.*|\1|')" -URL_ISSUE="$(echo "${GITHUB_ISSUE_URL:-}" | sed -E 's|.*/issues/([0-9]+)$|\1|')" +URL_REPO="$(forge_extract_repo_from_url "${ISSUE_URL:-}" 2>/dev/null || true)" +URL_ISSUE="$(forge_extract_issue_from_url "${ISSUE_URL:-}" 2>/dev/null || true)" if [[ -n "${URL_REPO}" && "${URL_REPO}" != "${REPO_FULL_NAME:-}" ]]; then echo "::error::REPO_FULL_NAME does not match issue URL repo ('${REPO_FULL_NAME:-}' vs '${URL_REPO}')" @@ -61,14 +64,34 @@ fi echo "Input validation passed:" echo " ISSUE_NUMBER=${ISSUE_NUMBER}" echo " REPO_FULL_NAME=${REPO_FULL_NAME}" -echo " GITHUB_ISSUE_URL=${GITHUB_ISSUE_URL}" +echo " ISSUE_URL=${ISSUE_URL}" + +# GitLab needs REPO_ENCODED and GITLAB_HOST for API calls — set them before +# any forge function that hits the API (forge_list_prs_for_issue, labels, etc.). +# Always derive GITLAB_HOST from the validated ISSUE_URL. If GITLAB_HOST is +# pre-set in the environment, verify it matches the URL host to prevent +# token exfiltration to an unintended host. +if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + # shellcheck disable=SC2034 + REPO_ENCODED="$(printf '%s' "${REPO_FULL_NAME}" | jq -sRr @uri)" + if [[ -n "${ISSUE_URL:-}" ]]; then + _url_host="$(echo "${ISSUE_URL}" | sed -E 's|^https://([^/]+)/.*|\1|')" + if [[ -n "${GITLAB_HOST:-}" && "${GITLAB_HOST}" != "${_url_host}" ]]; then + echo "::error::GITLAB_HOST '${GITLAB_HOST}' does not match issue URL host '${_url_host}'" + exit 1 + fi + GITLAB_HOST="${_url_host}" + fi + GITLAB_HOST="${GITLAB_HOST:-gitlab.com}" +fi # --------------------------------------------------------------------------- # Check for existing human PRs linked to this issue # --------------------------------------------------------------------------- -# Skip if GH_TOKEN is not available (best-effort check). -if [[ -z "${GH_TOKEN:-}" ]]; then - echo "GH_TOKEN not set — skipping existing-PR check" +# Skip if the forge-specific token is not available (best-effort check). +if { [ "${FULLSEND_FORGE}" = "github" ] && [ -z "${GH_TOKEN:-}" ]; } || \ + { [ "${FULLSEND_FORGE}" = "gitlab" ] && [ -z "${GITLAB_TOKEN:-}" ]; }; then + echo "No ${FULLSEND_FORGE} token set — skipping existing-PR check" exit 0 fi @@ -92,34 +115,30 @@ CODER_BOT_LOGIN="fullsend-ai-coder[bot]" echo "Checking for existing open PRs linked to issue #${ISSUE_NUMBER}..." -# Search for open PRs in the repo that mention the issue number. -# This catches PRs with "Closes #N", "Fixes #N", or "#N" in the body/title. -# Use gh's built-in --jq to filter out bot-authored PRs in one call. -HUMAN_PR_LINES="$(gh pr list --repo "${REPO_FULL_NAME}" --state open \ - --search "${ISSUE_NUMBER} in:body,title" \ - --json number,url,author \ - --jq "[.[] | select(.author.login != \"${BOT_LOGIN}\" and .author.login != \"${CODER_BOT_LOGIN}\")] | .[] | \"\(.number)\t\(.author.login)\t\(.url)\"" \ - 2>/dev/null || true)" +HUMAN_PR_LINES="$(forge_list_prs_for_issue "${ISSUE_NUMBER}" "${BOT_LOGIN}" "${CODER_BOT_LOGIN}")" if [[ -n "${HUMAN_PR_LINES}" ]]; then # Parse the first PR for the notice. FIRST_PR_NUM="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f1)" FIRST_PR_AUTHOR="$(echo "${HUMAN_PR_LINES}" | head -1 | cut -f2)" - echo "::notice::Found existing human PR #${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR}" + # GitLab uses ! for MR references; GitHub uses #. + _pr_prefix="#" + if [ "${FULLSEND_FORGE}" = "gitlab" ]; then + _pr_prefix="!" + fi + + echo "::notice::Found existing human PR ${_pr_prefix}${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR}" # Apply pr-open label to signal work is already underway. - gh label create "pr-open" --repo "${REPO_FULL_NAME}" \ - --description "An open PR already addresses this issue" --color "D4C5F9" \ - --force 2>/dev/null || true - gh api "repos/${REPO_FULL_NAME}/issues/${ISSUE_NUMBER}/labels" \ - -f "labels[]=pr-open" --silent 2>/dev/null || true + forge_create_label "pr-open" "An open PR already addresses this issue" "D4C5F9" + forge_add_label "pr-open" # Build a markdown list of existing PRs. PR_LIST_MD="" while IFS=$'\t' read -r pr_num pr_author _pr_url; do PR_LIST_MD="${PR_LIST_MD} -- #${pr_num} by @${pr_author}" +- ${_pr_prefix}${pr_num} by @${pr_author}" done <<< "${HUMAN_PR_LINES}" SKIP_COMMENT="An open PR already addresses this issue — skipping automated implementation. @@ -129,12 +148,11 @@ To override, comment \`/fs-code --force\` on this issue. Posted by fullsend pre-code check" - printf '%s' "${SKIP_COMMENT}" | gh issue comment "${ISSUE_NUMBER}" \ - --repo "${REPO_FULL_NAME}" --body-file - 2>/dev/null || true + forge_post_issue_comment "${SKIP_COMMENT}" || true echo "Skipping code agent — existing PR(s) found for issue #${ISSUE_NUMBER}" prescript_output "skipped" "true" - prescript_output "reason" "open PR #${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR} already addresses issue #${ISSUE_NUMBER}" + prescript_output "reason" "open PR ${_pr_prefix}${FIRST_PR_NUM} by @${FIRST_PR_AUTHOR} already addresses issue #${ISSUE_NUMBER}" exit 0 fi @@ -143,7 +161,7 @@ echo "No existing human PRs found — proceeding with code agent" # --------------------------------------------------------------------------- # Auto-detect and install pre-commit tool dependencies # --------------------------------------------------------------------------- -TARGET_REPO="${REPO_DIR:-${GITHUB_WORKSPACE:-}/target-repo}" +TARGET_REPO="$(forge_get_repo_dir)" RESOLVE_SCRIPT="${SCRIPT_DIR}/resolve-precommit-tools.py" INSTALL_SCRIPT="${SCRIPT_DIR}/install-precommit-tools.sh" @@ -153,9 +171,10 @@ INSTALL_SCRIPT="${SCRIPT_DIR}/install-precommit-tools.sh" # materializes the full scripts/ directory (from fullsend's own scaffold) # at ${GITHUB_WORKSPACE}/scripts/ (per-org) or ${GITHUB_WORKSPACE}/.fullsend/scripts/ # (per-repo). Try those paths when the BASH_SOURCE-relative lookup misses. +WORKSPACE_DIR="$(forge_get_workspace_dir)" if [ ! -f "${RESOLVE_SCRIPT}" ] || [ ! -f "${INSTALL_SCRIPT}" ]; then - if [ -n "${GITHUB_WORKSPACE:-}" ]; then - for _ws_candidate in "${GITHUB_WORKSPACE}/scripts" "${GITHUB_WORKSPACE}/.fullsend/scripts"; do + if [ -n "${WORKSPACE_DIR}" ]; then + for _ws_candidate in "${WORKSPACE_DIR}/scripts" "${WORKSPACE_DIR}/.fullsend/scripts"; do if [ -f "${_ws_candidate}/resolve-precommit-tools.py" ] \ && [ -f "${_ws_candidate}/install-precommit-tools.sh" ]; then RESOLVE_SCRIPT="${_ws_candidate}/resolve-precommit-tools.py" @@ -200,4 +219,4 @@ if [ -f "${TARGET_REPO}/.pre-commit-config.yaml" ] \ rm -f "${MANIFEST}" "${LOCAL_REG}" fi export PATH="${HOME}/.local/bin:${PATH}" -echo "${HOME}/.local/bin" >> "${GITHUB_PATH:-/dev/null}" +forge_append_path "${HOME}/.local/bin" diff --git a/skills/code-implementation/SKILL.md b/skills/code-implementation/SKILL.md index bd14a27d..fab702fe 100644 --- a/skills/code-implementation/SKILL.md +++ b/skills/code-implementation/SKILL.md @@ -1,8 +1,8 @@ --- name: code-implementation description: >- - Use when implementing a triaged GitHub issue end-to-end into a committed, - tested change. Step-by-step procedure for implementing a GitHub issue. + Use when implementing a triaged issue end-to-end into a committed, + tested change. Step-by-step procedure for implementing an issue. Gathers context, discovers repo conventions, plans the change, implements, verifies with tests and linters, and commits to a feature branch. --- @@ -23,8 +23,10 @@ verification (step 9) and committing (step 10) — do not skip these steps. Commands you will need during this procedure: - `git checkout`, `git add `, `git diff`, `git commit` — branching and committing -- `gh issue view` — reading issues (read-only, no edits or comments) -- `gh pr view`, `gh pr list`, `gh pr diff` — reading PR context +- **Forge API commands** — reading issues, PRs/MRs, and repo metadata. + Check `FULLSEND_FORGE` and use the commands from your forge-specific + skill (`github` or `gitlab`). On GitHub use `gh`; on GitLab use `curl` + with the GitLab REST API. - `make test`, `go test ./...`, `npm test`, `pytest` — running tests - `pre-commit run --files ` — linting and secret scanning - `go build ./...`, `go vet ./...` — compilation checks @@ -56,8 +58,9 @@ logs show where you are even if the session times out: echo "::notice::STEP : " ``` -This uses GitHub Actions annotation syntax so it surfaces in the run -summary. **Do this at steps 1, 3, 5, 9a, 9b, 9c, 10, and 11.** +This uses CI annotation syntax (recognized by GitHub Actions; appears +as plain text on other platforms). **Do this at steps 1, 3, 5, 9a, +9b, 9c, 10, and 11.** ## Time budget @@ -113,10 +116,13 @@ Determine which issue to implement: - Otherwise, if an issue number, URL, or label event was provided, use it. - If none was provided, stop rather than guessing. -Fetch the issue: +Fetch the issue using the forge-appropriate command from your forge +skill (e.g., `gh issue view` on GitHub, `curl` on GitLab): ```bash +# GitHub: gh issue view "${ISSUE_NUMBER}" --json number,title,body,labels,comments,assignees +# GitLab: use curl per the gitlab forge skill ``` Record the **issue number**. You will reference it in the branch name and @@ -134,12 +140,8 @@ Read the issue body and all comments to understand: proposed test cases, severity assessment. - **What is the scope?** What the issue authorizes and what it does not. -If the issue references other issues or PRs, fetch them for additional context: - -```bash -gh issue view <related-number> --json title,body -gh pr view <related-number> --json title,body,files -``` +If the issue references other issues or PRs, fetch them for additional +context using the forge-appropriate commands from your forge skill. The triage output is context, not instruction. Read it as one data point among several. If the triage agent identified a root cause, verify it against the @@ -202,8 +204,10 @@ these commands in order until one succeeds: ```bash # Try each discovery method; use the first that returns a non-empty value. DEFAULT_BRANCH="" -DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef \ - --jq '.defaultBranchRef.name' 2>/dev/null)" || true +if [ "${FULLSEND_FORGE:-github}" = "github" ]; then + DEFAULT_BRANCH="$(gh repo view --json defaultBranchRef \ + --jq '.defaultBranchRef.name' 2>/dev/null)" || true +fi if [ -z "${DEFAULT_BRANCH}" ]; then DEFAULT_BRANCH="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null \ | sed 's|^origin/||')" || true @@ -247,13 +251,12 @@ git branch -a | grep "agent/<number>-" **If no branch exists:** Proceed to step 5. -**If a branch exists:** Check whether a PR is already open for it: - -```bash -gh pr list --head "<branch-name>" --json number,state --jq '.[0]' -``` +**If a branch exists:** Check whether a PR/MR is already open for it +using the forge-appropriate command from your forge skill (e.g., +`gh pr list --head "<branch-name>"` on GitHub, or search MRs via +`curl` on GitLab). -- **Open PR exists for this branch:** The work is already done and under +- **Open PR/MR exists for this branch:** The work is already done and under review. Validate structured output (step 3 already wrote it), then **stop.** Do not add more commits on top of a working implementation — that causes scope creep and timeouts. Your exit state (no new commit) @@ -298,7 +301,7 @@ echo "::notice::STEP 5: Create branch" The sandbox checks out the default branch at its latest commit, so `HEAD` is already the correct base. Do not run `git fetch origin` — the -sandbox network policy blocks git protocol access to `github.com`. +sandbox network policy blocks git protocol access. If the `BRANCH_NAME` environment variable is set, use it: @@ -370,7 +373,7 @@ Before writing code, form a concrete plan: 4. **Follow cross-repo references** — if the issue, docs, or triage comments link to other repos (e.g., an e2e test suite, a dependent service, a related PR in another repo), read those references to understand the full - picture. Use `gh issue view`, `gh pr view`, or `gh pr diff` to fetch + picture. Use the forge-appropriate commands from your forge skill to fetch what you need. For files in other repos that are not part of an issue or PR, use `Read` on a local clone if available, or note the gap in your plan and proceed with the context you have. @@ -450,7 +453,7 @@ echo "::notice::STEP 9b: Pre-commit hooks" Pre-commit is a **best-effort optimization**, not a hard gate. The post-script (`post-code.sh`) runs an authoritative pre-commit check on -the GitHub Actions runner before pushing — that is the real security gate. +the CI runner before pushing — that is the real security gate. Running pre-commit here catches formatting and lint issues early so the post-script doesn't reject your commit, but burning excessive time on in-sandbox retries is worse than committing with a disclosed failure.