From b75ce3dbe0ed1b8c23b5f057f65028c02006187e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Mic=C3=B3?= Date: Wed, 15 Jul 2026 15:13:40 +0200 Subject: [PATCH] fix: make code mode provider-aware instead of hardcoding GitHub `openwiki code` always wrote `.github/workflows/openwiki-update.yml` and hardcoded "GitHub Actions workflow" wording in the AGENTS.md/CLAUDE.md OPENWIKI block, even on GitLab/Bitbucket repos. This reintroduced GitHub-specific artifacts on every run that CI then had to scrub. Detect the git host from the repository remote (overridable via `OPENWIKI_CI_PROVIDER=github|gitlab|bitbucket|none`) and: - only write the GitHub Actions workflow when the host is GitHub; - phrase the OPENWIKI block to match the detected host (GitHub Actions workflow / GitLab pipeline / Bitbucket pipeline / neutral update job). Undetectable hosts and missing remotes fall back to GitHub, preserving the historical default. The GitLab/Bitbucket example pipelines now set the provider explicitly and no longer track the GitHub workflow path. Fixes #341 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- .../openwiki-update.bitbucket-pipelines.yml | 8 +- examples/openwiki-update.gitlab-ci.yml | 8 +- src/code-mode.ts | 131 +++++++++++++++++- test/code-mode.test.ts | 129 +++++++++++++++++ 5 files changed, 270 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b4262e8f..70412b37 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,12 @@ personal brain wiki. Then to ensure your documentation stays up-to-date, add the CI workflow for your Git provider to automatically open a PR or merge request with documentation updates: -- GitHub Actions: copy [openwiki-update.yml](./examples/openwiki-update.yml) into `.github/workflows/openwiki-update.yml`. +- GitHub Actions: OpenWiki writes `.github/workflows/openwiki-update.yml` for you when it detects a GitHub remote. You can also copy [openwiki-update.yml](./examples/openwiki-update.yml) manually. - GitLab CI: copy [openwiki-update.gitlab-ci.yml](./examples/openwiki-update.gitlab-ci.yml) into `.gitlab-ci.yml` or include it from your existing GitLab pipeline. - Bitbucket Pipelines: copy [openwiki-update.bitbucket-pipelines.yml](./examples/openwiki-update.bitbucket-pipelines.yml) into `bitbucket-pipelines.yml`, then schedule the `openwiki-update` custom pipeline from Repository settings > Pipelines > Schedules. +OpenWiki detects your Git host from the repository's remote and only writes the GitHub Actions workflow for GitHub repositories; GitLab and Bitbucket repos are left untouched (use the examples above). Override detection with `OPENWIKI_CI_PROVIDER=github|gitlab|bitbucket|none` — set it to `none` to stop OpenWiki from writing any CI workflow (for example in a pipeline that manages its own pull requests). + For repository documentation in GitHub Actions, use `openwiki code --update --print`. You do not need to run `--init` in CI: `--update` will create the initial `openwiki/` docs if they do not exist yet, as @@ -150,7 +152,7 @@ Bare `openwiki` runs in code mode for the current repository. It creates initial Bare `openwiki --init` and `openwiki --update` default to code mode and operate on repository documentation. Use the `personal` positional mode or `--mode personal` to initialize or update the local personal brain wiki. -On each `code` run, `openwiki` maintains both an `AGENTS.md` and a `CLAUDE.md` at the repository root, adding prompting that instructs your coding agent to reference the wiki when searching for context. Each file is created if it does not already exist. If a file is present, OpenWiki only rewrites its own `` block and leaves the rest of your content untouched (appending the block the first time). The scheduled GitHub Actions workflow includes these files, along with the workflow itself, in the documentation pull request. +On each `code` run, `openwiki` maintains both an `AGENTS.md` and a `CLAUDE.md` at the repository root, adding prompting that instructs your coding agent to reference the wiki when searching for context. Each file is created if it does not already exist. If a file is present, OpenWiki only rewrites its own `` block and leaves the rest of your content untouched (appending the block the first time). The block's wording matches your detected Git host (GitHub Actions workflow, GitLab pipeline, Bitbucket pipeline, or a provider-neutral update job). The scheduled GitHub Actions workflow includes these files, along with the workflow itself, in the documentation pull request. Repository-specific wiki instructions are stored separately in `openwiki/INSTRUCTIONS.md`. This file is a shared, user-authored brief for the diff --git a/examples/openwiki-update.bitbucket-pipelines.yml b/examples/openwiki-update.bitbucket-pipelines.yml index f899220f..ff006dde 100644 --- a/examples/openwiki-update.bitbucket-pipelines.yml +++ b/examples/openwiki-update.bitbucket-pipelines.yml @@ -12,15 +12,19 @@ pipelines: - npm install --global openwiki - git config user.name "${BITBUCKET_USER_NAME:-OpenWiki Bot}" - git config user.email "${BITBUCKET_USER_EMAIL:-openwiki@example.com}" + # Keep OpenWiki from writing a GitHub Actions workflow and make the + # AGENTS.md/CLAUDE.md wording reference Bitbucket. Detection from the + # git remote usually handles this; setting it here is bulletproof. + - export OPENWIKI_CI_PROVIDER=bitbucket - openwiki code --update --print - | - if git diff --quiet -- openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml; then + if git diff --quiet -- openwiki AGENTS.md CLAUDE.md; then echo "OpenWiki is already up to date." exit 0 fi - export OPENWIKI_BRANCH="openwiki/update-${BITBUCKET_BUILD_NUMBER}" - git checkout -b "$OPENWIKI_BRANCH" - - git add openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml + - git add openwiki AGENTS.md CLAUDE.md - git commit -m "docs: update OpenWiki" - git push "https://x-token-auth:${OPENWIKI_BITBUCKET_TOKEN}@bitbucket.org/${BITBUCKET_WORKSPACE}/${BITBUCKET_REPO_SLUG}.git" "$OPENWIKI_BRANCH" - | diff --git a/examples/openwiki-update.gitlab-ci.yml b/examples/openwiki-update.gitlab-ci.yml index c265f759..754fe98f 100644 --- a/examples/openwiki-update.gitlab-ci.yml +++ b/examples/openwiki-update.gitlab-ci.yml @@ -12,13 +12,13 @@ openwiki_update: script: - openwiki code --update --print - | - if git diff --quiet -- openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml; then + if git diff --quiet -- openwiki AGENTS.md CLAUDE.md; then echo "OpenWiki is already up to date." exit 0 fi - export OPENWIKI_BRANCH="openwiki/update-${CI_PIPELINE_ID}" - git checkout -b "$OPENWIKI_BRANCH" - - git add openwiki AGENTS.md CLAUDE.md .github/workflows/openwiki-update.yml + - git add openwiki AGENTS.md CLAUDE.md - git commit -m "docs: update OpenWiki" - git push "https://oauth2:${OPENWIKI_GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "$OPENWIKI_BRANCH" - | @@ -30,6 +30,10 @@ openwiki_update: --form "description=Automated OpenWiki documentation update generated by the scheduled GitLab pipeline." \ "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests" variables: + # Keep OpenWiki from writing a GitHub Actions workflow and make the + # AGENTS.md/CLAUDE.md wording reference GitLab. Detection from the git + # remote usually handles this, but setting it explicitly is bulletproof. + OPENWIKI_CI_PROVIDER: gitlab OPENWIKI_PROVIDER: openrouter OPENWIKI_MODEL_ID: z-ai/glm-5.2 LANGCHAIN_PROJECT: openwiki diff --git a/src/code-mode.ts b/src/code-mode.ts index f3447c5e..95245c28 100644 --- a/src/code-mode.ts +++ b/src/code-mode.ts @@ -1,11 +1,25 @@ +import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { isFileNotFoundError } from "./fs-errors.js"; +const execFileAsync = promisify(execFile); + const OPENWIKI_AGENTS_SNIPPET_START = ""; const OPENWIKI_AGENTS_SNIPPET_END = ""; const DEFAULT_CODE_MODE_CRON = "0 8 * * *"; +// Which CI host OpenWiki tailors its generated artifacts to. Only "github" +// gets a committed CI workflow (GitHub Actions); the others ship as examples/ +// the user wires up themselves, so we just keep the agent-file wording honest. +type CiProvider = "github" | "gitlab" | "bitbucket" | "none"; + +// Environment override for provider detection, useful for self-hosted hosts +// whose remote URL doesn't advertise the provider, or to opt out entirely +// (e.g. `OPENWIKI_CI_PROVIDER=none` in a CI pipeline that manages its own PRs). +const CI_PROVIDER_ENV_KEY = "OPENWIKI_CI_PROVIDER"; + // Root agent-instruction files OpenWiki keeps pointed at the generated wiki. // Each is created when missing and refreshed in place when already present. const CODE_MODE_AGENT_FILES = ["AGENTS.md", "CLAUDE.md"]; @@ -14,8 +28,95 @@ export async function ensureCodeModeRepoSetup( cwd: string, cronExpression = DEFAULT_CODE_MODE_CRON, ): Promise { - await writeCodeModeWorkflow(cwd, cronExpression); - await writeCodeModeAgentSnippets(cwd); + const provider = await detectCiProvider(cwd); + + if (provider === "github") { + await writeCodeModeWorkflow(cwd, cronExpression); + } + + await writeCodeModeAgentSnippets(cwd, provider); +} + +async function detectCiProvider(cwd: string): Promise { + const override = normalizeCiProvider(process.env[CI_PROVIDER_ENV_KEY]); + if (override) { + return override; + } + + return classifyRemoteHost(await readGitRemoteUrl(cwd)); +} + +function normalizeCiProvider(value: string | undefined): CiProvider | null { + switch (value?.trim().toLowerCase()) { + case "github": + return "github"; + case "gitlab": + return "gitlab"; + case "bitbucket": + return "bitbucket"; + case "none": + return "none"; + default: + return null; + } +} + +// Reads the repo's push/fetch remote URL so we can infer the git host. Prefers +// `origin`, falling back to whatever remote exists. Returns null when there is +// no git repo or no remote configured. +async function readGitRemoteUrl(cwd: string): Promise { + const originUrl = await runGitQuietly(cwd, ["remote", "get-url", "origin"]); + if (originUrl) { + return originUrl; + } + + const remotes = await runGitQuietly(cwd, ["remote"]); + const firstRemote = remotes + ?.split(/\r?\n/u) + .map((line) => line.trim()) + .find(Boolean); + if (!firstRemote) { + return null; + } + + return runGitQuietly(cwd, ["remote", "get-url", firstRemote]); +} + +async function runGitQuietly( + cwd: string, + args: string[], +): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 1024 * 1024, + }); + const trimmed = stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + // No git binary, not a repo, or no such remote — treat as undetectable. + return null; + } +} + +// Classifies a git remote URL by host. Enterprise/self-hosted GitHub and GitLab +// instances typically keep the provider name in their hostname, so a substring +// match is deliberate. Unknown hosts (and missing remotes) fall back to GitHub +// to preserve the historical default; use OPENWIKI_CI_PROVIDER to override. +function classifyRemoteHost(remoteUrl: string | null): CiProvider { + if (!remoteUrl) { + return "github"; + } + + const lower = remoteUrl.toLowerCase(); + if (lower.includes("gitlab")) { + return "gitlab"; + } + if (lower.includes("bitbucket")) { + return "bitbucket"; + } + + return "github"; } async function writeCodeModeWorkflow( @@ -32,8 +133,11 @@ async function writeCodeModeWorkflow( await writeFile(workflowPath, createCodeModeWorkflow(cronExpression), "utf8"); } -async function writeCodeModeAgentSnippets(cwd: string): Promise { - const snippet = createCodeModeAgentsSnippet(); +async function writeCodeModeAgentSnippets( + cwd: string, + provider: CiProvider, +): Promise { + const snippet = createCodeModeAgentsSnippet(provider); await Promise.all( CODE_MODE_AGENT_FILES.map((fileName) => @@ -121,14 +225,29 @@ jobs: `; } -function createCodeModeAgentsSnippet(): string { +function createCodeModeAgentsSnippet(provider: CiProvider): string { return `${OPENWIKI_AGENTS_SNIPPET_START} ## OpenWiki This repository uses OpenWiki for recurring code documentation. Start with \`openwiki/quickstart.md\`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps. -The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate. +The ${describeCodeModeUpdateJob(provider)} refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate. ${OPENWIKI_AGENTS_SNIPPET_END}`; } + +// Provider-aware phrasing for the OPENWIKI agent-file block so the sentence is +// accurate on non-GitHub hosts instead of always claiming a GitHub Actions run. +function describeCodeModeUpdateJob(provider: CiProvider): string { + switch (provider) { + case "github": + return "scheduled OpenWiki GitHub Actions workflow"; + case "gitlab": + return "scheduled OpenWiki GitLab pipeline"; + case "bitbucket": + return "scheduled OpenWiki Bitbucket pipeline"; + case "none": + return "scheduled OpenWiki update job"; + } +} diff --git a/test/code-mode.test.ts b/test/code-mode.test.ts index 11df0650..d580d36d 100644 --- a/test/code-mode.test.ts +++ b/test/code-mode.test.ts @@ -1,12 +1,18 @@ +import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, test } from "vitest"; import { ensureCodeModeRepoSetup } from "../src/code-mode.ts"; +const execFileAsync = promisify(execFile); + const SNIPPET_START = ""; const SNIPPET_END = ""; +const CI_PROVIDER_ENV_KEY = "OPENWIKI_CI_PROVIDER"; + const tempRepos: string[] = []; async function createTempRepo(): Promise { @@ -15,6 +21,39 @@ async function createTempRepo(): Promise { return repo; } +const WORKFLOW_RELATIVE_PATH = path.join( + ".github", + "workflows", + "openwiki-update.yml", +); + +async function withCiProvider( + value: string, + run: () => Promise, +): Promise { + const previous = process.env[CI_PROVIDER_ENV_KEY]; + process.env[CI_PROVIDER_ENV_KEY] = value; + try { + await run(); + } finally { + if (previous === undefined) { + delete process.env[CI_PROVIDER_ENV_KEY]; + } else { + process.env[CI_PROVIDER_ENV_KEY] = previous; + } + } +} + +async function initGitRepoWithRemote( + repo: string, + remoteUrl: string, +): Promise { + await execFileAsync("git", ["init"], { cwd: repo }); + await execFileAsync("git", ["remote", "add", "origin", remoteUrl], { + cwd: repo, + }); +} + async function readIfPresent(filePath: string): Promise { try { return await readFile(filePath, "utf8"); @@ -119,4 +158,94 @@ describe("ensureCodeModeRepoSetup workflow", () => { expect(workflow).toContain(managedPath); } }); + + test("defaults to GitHub wording and workflow when no git remote exists", async () => { + const repo = await createTempRepo(); + + await ensureCodeModeRepoSetup(repo); + + const workflow = await readIfPresent( + path.join(repo, WORKFLOW_RELATIVE_PATH), + ); + expect( + workflow, + "GitHub workflow should be written by default", + ).not.toBeNull(); + + const claude = await readIfPresent(path.join(repo, "CLAUDE.md")); + expect(claude).toContain( + "scheduled OpenWiki GitHub Actions workflow refreshes", + ); + }); +}); + +describe("ensureCodeModeRepoSetup provider awareness", () => { + test.each([ + { + provider: "gitlab", + wording: "scheduled OpenWiki GitLab pipeline refreshes", + }, + { + provider: "bitbucket", + wording: "scheduled OpenWiki Bitbucket pipeline refreshes", + }, + { + provider: "none", + wording: "scheduled OpenWiki update job refreshes", + }, + ])( + "skips the GitHub workflow and uses matching wording for $provider", + async ({ provider, wording }) => { + const repo = await createTempRepo(); + + await withCiProvider(provider, () => ensureCodeModeRepoSetup(repo)); + + const workflow = await readIfPresent( + path.join(repo, WORKFLOW_RELATIVE_PATH), + ); + expect( + workflow, + `no GitHub workflow should be written for ${provider}`, + ).toBeNull(); + + for (const fileName of ["AGENTS.md", "CLAUDE.md"]) { + const content = await readIfPresent(path.join(repo, fileName)); + expect(content).toContain(wording); + expect(content).not.toContain("GitHub Actions workflow"); + } + }, + ); + + test("detects the provider from the git remote host", async () => { + const repo = await createTempRepo(); + await initGitRepoWithRemote(repo, "git@bitbucket.org:acme/widgets.git"); + + await ensureCodeModeRepoSetup(repo); + + const workflow = await readIfPresent( + path.join(repo, WORKFLOW_RELATIVE_PATH), + ); + expect( + workflow, + "Bitbucket repos should not get a GitHub workflow", + ).toBeNull(); + + const claude = await readIfPresent(path.join(repo, "CLAUDE.md")); + expect(claude).toContain("scheduled OpenWiki Bitbucket pipeline refreshes"); + }); + + test("env override wins over the detected git remote host", async () => { + const repo = await createTempRepo(); + await initGitRepoWithRemote(repo, "git@github.com:acme/widgets.git"); + + await withCiProvider("none", () => ensureCodeModeRepoSetup(repo)); + + const workflow = await readIfPresent( + path.join(repo, WORKFLOW_RELATIVE_PATH), + ); + expect(workflow).toBeNull(); + + const claude = await readIfPresent(path.join(repo, "CLAUDE.md")); + expect(claude).toContain("scheduled OpenWiki update job refreshes"); + }); });