diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 9b53df54..153831db 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -97,3 +97,16 @@ jobs: run: docker version && docker compose version - name: OpenCode autoconnect run: bash scripts/e2e/opencode-autoconnect.sh + + e2e-login-context: + name: E2E (Login context) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + uses: ./.github/actions/setup + - name: Docker info + run: docker version && docker compose version + - name: Login context notice + run: bash scripts/e2e/login-context.sh diff --git a/packages/docker-git/tests/core/templates.test.ts b/packages/docker-git/tests/core/templates.test.ts index b919777c..3f1bfc1b 100644 --- a/packages/docker-git/tests/core/templates.test.ts +++ b/packages/docker-git/tests/core/templates.test.ts @@ -139,6 +139,52 @@ describe("planFiles", () => { expect(entrypointSpec.contents).toContain("Issue AGENTS.md:") expect(entrypointSpec.contents).toContain("ISSUE_AGENTS_PATH=\"$TARGET_DIR/AGENTS.md\"") expect(entrypointSpec.contents).toContain("grep -qx \"AGENTS.md\" \"$EXCLUDE_PATH\"") + expect(entrypointSpec.contents).toContain("docker_git_workspace_context_line()") + expect(entrypointSpec.contents).toContain("REPO_REF_VALUE=\"${REPO_REF:-issue-5}\"") + expect(entrypointSpec.contents).toContain("REPO_URL_VALUE=\"${REPO_URL:-https://github.com/org/repo.git}\"") + expect(entrypointSpec.contents).toContain("Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)") + } + })) + + it.effect("embeds PR workspace URL context in entrypoint", () => + Effect.sync(() => { + const config: TemplateConfig = { + containerName: "dg-repo-pr-42", + serviceName: "dg-repo-pr-42", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "refs/pull/42/head", + targetDir: "/home/dev/org/repo/pr-42", + volumeName: "dg-repo-pr-42-home", + authorizedKeysPath: "./authorized_keys", + envGlobalPath: "./.orch/env/global.env", + envProjectPath: "./.orch/env/project.env", + codexAuthPath: "./.orch/auth/codex", + codexSharedAuthPath: "../../.orch/auth/codex", + codexHome: "/home/dev/.codex", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + const specs = planFiles(config) + const entrypointSpec = specs.find( + (spec) => spec._tag === "File" && spec.relativePath === "entrypoint.sh" + ) + expect(entrypointSpec !== undefined && entrypointSpec._tag === "File").toBe(true) + if (entrypointSpec && entrypointSpec._tag === "File") { + expect(entrypointSpec.contents).toContain("REPO_REF_VALUE=\"${REPO_REF:-refs/pull/42/head}\"") + expect(entrypointSpec.contents).toContain("REPO_URL_VALUE=\"${REPO_URL:-https://github.com/org/repo.git}\"") + expect(entrypointSpec.contents).toContain( + "PR_ID=\"$(printf \"%s\" \"$REPO_REF\" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')\"" + ) + expect(entrypointSpec.contents).toContain( + "PR_URL=\"https://github.com/$PR_REPO/pull/$PR_ID\"" + ) + expect(entrypointSpec.contents).toContain( + "WORKSPACE_INFO_LINE=\"Контекст workspace: PR #$PR_ID ($PR_URL)\"" + ) + expect(entrypointSpec.contents).toContain("Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)") } })) }) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 7660209e..13a2ff81 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -44,7 +44,7 @@ export const renderEntrypoint = (config: TemplateConfig): string => renderEntrypointBashHistory(), renderEntrypointInputRc(config), renderEntrypointZshConfig(), - renderEntrypointCodexResumeHint(), + renderEntrypointCodexResumeHint(config), renderEntrypointAgentsNotice(config), renderEntrypointDockerSocket(config), renderEntrypointGitConfig(config), diff --git a/packages/lib/src/core/templates-entrypoint/codex.ts b/packages/lib/src/core/templates-entrypoint/codex.ts index 33e2b489..12395a65 100644 --- a/packages/lib/src/core/templates-entrypoint/codex.ts +++ b/packages/lib/src/core/templates-entrypoint/codex.ts @@ -102,27 +102,76 @@ export const renderEntrypointMcpPlaywright = (config: TemplateConfig): string => .replaceAll("__CODEX_HOME__", config.codexHome) .replaceAll("__SERVICE_NAME__", config.serviceName) -export const renderEntrypointCodexResumeHint = (): string => - `# Ensure codex resume hint is shown for interactive shells +const entrypointCodexResumeHintTemplate = `# Ensure codex resume hint is shown for interactive shells CODEX_HINT_PATH="/etc/profile.d/zz-codex-resume.sh" if [[ ! -s "$CODEX_HINT_PATH" ]]; then cat <<'EOF' > "$CODEX_HINT_PATH" +docker_git_workspace_context_line() { + REPO_REF_VALUE="\${REPO_REF:-__REPO_REF_DEFAULT__}" + REPO_URL_VALUE="\${REPO_URL:-__REPO_URL_DEFAULT__}" + + if [[ "$REPO_REF_VALUE" == issue-* ]]; then + ISSUE_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -E 's#^issue-##')" + ISSUE_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* ]]; then + ISSUE_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$ISSUE_REPO_VALUE" ]]; then + ISSUE_URL_VALUE="https://github.com/$ISSUE_REPO_VALUE/issues/$ISSUE_ID_VALUE" + fi + fi + if [[ -n "$ISSUE_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE ($ISSUE_URL_VALUE)" + else + printf "%s\n" "Контекст workspace: issue #$ISSUE_ID_VALUE" + fi + return + fi + + if [[ "$REPO_REF_VALUE" == refs/pull/*/head ]]; then + PR_ID_VALUE="$(printf "%s" "$REPO_REF_VALUE" | sed -nE 's#^refs/pull/([0-9]+)/head$#\\1#p')" + PR_URL_VALUE="" + if [[ "$REPO_URL_VALUE" == https://github.com/* && -n "$PR_ID_VALUE" ]]; then + PR_REPO_VALUE="$(printf "%s" "$REPO_URL_VALUE" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO_VALUE" ]]; then + PR_URL_VALUE="https://github.com/$PR_REPO_VALUE/pull/$PR_ID_VALUE" + fi + fi + if [[ -n "$PR_ID_VALUE" && -n "$PR_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE ($PR_URL_VALUE)" + elif [[ -n "$PR_ID_VALUE" ]]; then + printf "%s\n" "Контекст workspace: PR #$PR_ID_VALUE" + elif [[ -n "$REPO_REF_VALUE" ]]; then + printf "%s\n" "Контекст workspace: pull request ($REPO_REF_VALUE)" + fi + return + fi + + if [[ -n "$REPO_URL_VALUE" ]]; then + printf "%s\n" "Контекст workspace: $REPO_URL_VALUE" + fi +} + +docker_git_print_codex_resume_hint() { + if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then + DOCKER_GIT_CONTEXT_LINE="$(docker_git_workspace_context_line)" + if [[ -n "$DOCKER_GIT_CONTEXT_LINE" ]]; then + echo "$DOCKER_GIT_CONTEXT_LINE" + fi + echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." + export CODEX_RESUME_HINT_SHOWN=1 + fi +} + if [ -n "$BASH_VERSION" ]; then case "$-" in *i*) - if [ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi + docker_git_print_codex_resume_hint ;; esac fi if [ -n "$ZSH_VERSION" ]; then if [[ "$-" == *i* ]]; then - if [[ -z "\${CODEX_RESUME_HINT_SHOWN-}" ]]; then - echo "Старые сессии можно запустить с помощью codex resume или codex resume , если знаешь айди." - export CODEX_RESUME_HINT_SHOWN=1 - fi + docker_git_print_codex_resume_hint fi fi EOF @@ -135,6 +184,21 @@ if [[ -s /etc/zsh/zshrc ]] && ! grep -q "zz-codex-resume.sh" /etc/zsh/zshrc 2>/d printf "%s\\n" "if [ -f /etc/profile.d/zz-codex-resume.sh ]; then source /etc/profile.d/zz-codex-resume.sh; fi" >> /etc/zsh/zshrc fi` +const escapeForDoubleQuotes = (value: string): string => { + const backslash = String.fromCodePoint(92) + const quote = String.fromCodePoint(34) + const escapedBackslash = `${backslash}${backslash}` + const escapedQuote = `${backslash}${quote}` + return value + .replaceAll(backslash, escapedBackslash) + .replaceAll(quote, escapedQuote) +} + +export const renderEntrypointCodexResumeHint = (config: TemplateConfig): string => + entrypointCodexResumeHintTemplate + .replaceAll("__REPO_REF_DEFAULT__", escapeForDoubleQuotes(config.repoRef)) + .replaceAll("__REPO_URL_DEFAULT__", escapeForDoubleQuotes(config.repoUrl)) + const entrypointAgentsNoticeTemplate = String.raw`# Ensure global AGENTS.md exists for container context AGENTS_PATH="__CODEX_HOME__/AGENTS.md" LEGACY_AGENTS_PATH="/home/__SSH_USER__/AGENTS.md" @@ -160,8 +224,17 @@ if [[ "$REPO_REF" == issue-* ]]; then fi ISSUE_AGENTS_HINT_LINE="Issue AGENTS.md: __TARGET_DIR__/AGENTS.md" elif [[ "$REPO_REF" == refs/pull/*/head ]]; then - PR_ID="$(printf "%s" "$REPO_REF" | sed -E 's#^refs/pull/([0-9]+)/head$#\1#')" - if [[ -n "$PR_ID" ]]; then + PR_ID="$(printf "%s" "$REPO_REF" | sed -nE 's#^refs/pull/([0-9]+)/head$#\1#p')" + PR_URL="" + if [[ "$REPO_URL" == https://github.com/* && -n "$PR_ID" ]]; then + PR_REPO="$(printf "%s" "$REPO_URL" | sed -E 's#^https://github.com/##; s#[.]git$##; s#/*$##')" + if [[ -n "$PR_REPO" ]]; then + PR_URL="https://github.com/$PR_REPO/pull/$PR_ID" + fi + fi + if [[ -n "$PR_ID" && -n "$PR_URL" ]]; then + WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID ($PR_URL)" + elif [[ -n "$PR_ID" ]]; then WORKSPACE_INFO_LINE="Контекст workspace: PR #$PR_ID" else WORKSPACE_INFO_LINE="Контекст workspace: pull request ($REPO_REF)" diff --git a/scripts/e2e/login-context.sh b/scripts/e2e/login-context.sh new file mode 100755 index 00000000..b8004295 --- /dev/null +++ b/scripts/e2e/login-context.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +RUN_ID="$(date +%s)-$RANDOM" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ROOT_BASE="${DOCKER_GIT_E2E_ROOT_BASE:-$REPO_ROOT/.docker-git/e2e-root}" +mkdir -p "$ROOT_BASE" +ROOT="$(mktemp -d "$ROOT_BASE/login-context.XXXXXX")" +SSH_KEY_BASE="$(mktemp -d /tmp/docker-git-login-context-key.XXXXXX)" +# docker-git containers may `chown -R` the `.docker-git` bind mount to UID 1000. +# Use world-writable permissions so the host runner can still create files +# even if ownership changes inside the container. +chmod 0777 "$ROOT" +mkdir -p "$ROOT/e2e" +chmod 0777 "$ROOT/e2e" +KEEP="${KEEP:-0}" + +export DOCKER_GIT_PROJECTS_ROOT="$ROOT" +export DOCKER_GIT_STATE_AUTO_SYNC=0 + +ACTIVE_OUT_DIR="" +ACTIVE_CONTAINER="" +ACTIVE_SERVICE="" + +fail() { + echo "e2e/login-context: $*" >&2 + exit 1 +} + +on_error() { + local line="$1" + echo "e2e/login-context: failed at line $line" >&2 + docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true + if [[ -n "$ACTIVE_OUT_DIR" ]] && [[ -f "$ACTIVE_OUT_DIR/docker-compose.yml" ]]; then + (cd "$ACTIVE_OUT_DIR" && docker compose ps) || true + (cd "$ACTIVE_OUT_DIR" && docker compose logs --no-color --tail 200) || true + fi +} + +cleanup_active_case() { + if [[ -n "$ACTIVE_OUT_DIR" ]] && [[ -f "$ACTIVE_OUT_DIR/docker-compose.yml" ]]; then + (cd "$ACTIVE_OUT_DIR" && docker compose down -v --remove-orphans) >/dev/null 2>&1 || true + fi + ACTIVE_OUT_DIR="" + ACTIVE_CONTAINER="" + ACTIVE_SERVICE="" +} + +cleanup() { + if [[ "$KEEP" == "1" ]]; then + echo "e2e/login-context: KEEP=1 set; preserving temp dir: $ROOT" >&2 + if [[ -n "$ACTIVE_CONTAINER" ]]; then + echo "e2e/login-context: active container: $ACTIVE_CONTAINER" >&2 + fi + if [[ -n "$ACTIVE_OUT_DIR" ]]; then + echo "e2e/login-context: active out dir: $ACTIVE_OUT_DIR" >&2 + fi + return + fi + cleanup_active_case + rm -rf "$ROOT" >/dev/null 2>&1 || true + rm -rf "$SSH_KEY_BASE" >/dev/null 2>&1 || true +} + +trap 'on_error $LINENO' ERR +trap cleanup EXIT + +command -v ssh >/dev/null 2>&1 || fail "missing 'ssh' command" +command -v timeout >/dev/null 2>&1 || fail "missing 'timeout' command" +command -v ssh-keygen >/dev/null 2>&1 || fail "missing 'ssh-keygen' command" + +ssh-keygen -q -t ed25519 -N "" -f "$SSH_KEY_BASE/dev_ssh_key" >/dev/null +cp "$SSH_KEY_BASE/dev_ssh_key.pub" "$ROOT/authorized_keys" +chmod 0600 "$SSH_KEY_BASE/dev_ssh_key" +chmod 0644 "$ROOT/authorized_keys" + +wait_for_ssh() { + local ssh_port="$1" + local attempts=30 + local attempt=1 + + while [[ "$attempt" -le "$attempts" ]]; do + if timeout 1 bash -lc "cat < /dev/null > /dev/tcp/127.0.0.1/$ssh_port" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + attempt="$((attempt + 1))" + done + + return 1 +} + +run_case() { + local case_name="$1" + local repo_url="$2" + local expected_context_line="$3" + local out_dir_rel=".docker-git/e2e/login-context-${case_name}-${RUN_ID}" + local out_dir="$ROOT/e2e/login-context-${case_name}-${RUN_ID}" + local container_name="dg-e2e-login-${case_name}-${RUN_ID}" + local service_name="dg-e2e-login-${case_name}-${RUN_ID}" + local volume_name="dg-e2e-login-${case_name}-${RUN_ID}-home" + local ssh_port="$(( (RANDOM % 1000) + 21000 ))" + local login_log="/tmp/docker-git-login-context-${RUN_ID}-${case_name}.log" + + mkdir -p "$out_dir/.orch/env" + chmod 0777 "$out_dir" "$out_dir/.orch" "$out_dir/.orch/env" + cat > "$out_dir/.orch/env/project.env" <<'EOF_ENV' +# docker-git project env (e2e) +CODEX_AUTO_UPDATE=0 +CODEX_SHARE_AUTH=1 +EOF_ENV + + ACTIVE_OUT_DIR="$out_dir" + ACTIVE_CONTAINER="$container_name" + ACTIVE_SERVICE="$service_name" + + ( + cd "$REPO_ROOT" + pnpm run docker-git clone "$repo_url" \ + --force \ + --no-ssh \ + --authorized-keys "$ROOT/authorized_keys" \ + --ssh-port "$ssh_port" \ + --out-dir "$out_dir_rel" \ + --container-name "$container_name" \ + --service-name "$service_name" \ + --volume-name "$volume_name" + ) + + wait_for_ssh "$ssh_port" || fail "ssh port did not open for $case_name (port: $ssh_port)" + + rm -f "$login_log" + + set +e + timeout 30s bash -lc "printf 'exit\n' | ssh -i \"$SSH_KEY_BASE/dev_ssh_key\" -tt -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p \"$ssh_port\" dev@localhost" > "$login_log" 2>&1 + local ssh_exit=$? + set -e + + if [[ "$ssh_exit" -ne 0 ]]; then + cat "$login_log" >&2 || true + fail "ssh login failed for $case_name (exit: $ssh_exit)" + fi + + grep -Fq -- "$expected_context_line" "$login_log" \ + || fail "expected context line not found for $case_name: $expected_context_line" + + grep -Fq -- "Старые сессии можно запустить с помощью codex resume" "$login_log" \ + || fail "expected codex resume hint for $case_name" + + cleanup_active_case +} + +run_case \ + "issue" \ + "https://github.com/octocat/Hello-World/issues/1" \ + "Контекст workspace: issue #1 (https://github.com/octocat/Hello-World/issues/1)" + +run_case \ + "pr" \ + "https://github.com/octocat/Hello-World/pull/1" \ + "Контекст workspace: PR #1 (https://github.com/octocat/Hello-World/pull/1)"