Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reconcile-run-environment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

Reconcile now tells Taskless whether it is running in CI. Every `POST /cli/api/v2/reconcile` carries `environment: { ci, provider? }`, using the CI detection telemetry already performs (`provider` is the hyphenated name, such as `github-actions`, when it is recognized). The service uses it only to record when a repository last reconciled from CI, which drives the dashboard's "CI set up" indicator; verdicts, entitlements and output are unchanged, and a local run sends `ci: false`.
25 changes: 24 additions & 1 deletion packages/cli/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";
import type { paths } from "../generated/api-v2";
import { getApiBaseUrl } from "./config";
import { parseRefusal, stripControlCharacters, type Refusal } from "./refusal";
import { resolveRunEnvironment } from "../util/adoption-dimensions";
import { isRecord } from "../util/is-record";
import { CLI_VERSION, CLI_VERSION_HEADER } from "../version";

Expand Down Expand Up @@ -558,6 +559,21 @@ export type ReconcileBody = NonNullable<
Operation<"/cli/api/v2/reconcile", "post">["requestBody"]
>["content"]["application/json"];

/**
* `environment` on the reconcile body, declared here until the vendored schema
* is refreshed.
*
* The live `/cli/api/v2/__schema` already publishes it as optional
* (`{ ci?: boolean; provider?: string }`, no other keys). Regenerating to pick
* it up would also vendor error codes the service has added since
* (`github_forbidden`, `github_unavailable`, `cli_upgrade_required`), which
* every v2 client must then handle; that is its own change. Once the schema
* is re-vendored this intersection is redundant and can be deleted.
*/
type ReconcileRequest = ReconcileBody & {
environment?: { ci?: boolean; provider?: string };
};

export type ReconcileResult = OkBody<"/cli/api/v2/reconcile", "post">;

export type ReconcileCode = ErrorCode<"/cli/api/v2/reconcile", "post">;
Expand All @@ -579,8 +595,15 @@ export function reconcileRules(
body: ReconcileBody
): Promise<V2Outcome<ReconcileResult, ReconcileCode>> {
const client = createV2Client(token);
// Attached here rather than by each caller, so no reconcile can be sent
// without it. The service only records when a repository last reconciled
// from CI; verdicts are identical with or without it.
const request: ReconcileRequest = {
...body,
environment: resolveRunEnvironment(),
};
return settle<ReconcileResult, ReconcileCode>(
() => client.POST("/cli/api/v2/reconcile", { body }),
() => client.POST("/cli/api/v2/reconcile", { body: request }),
RECONCILE_CODES,
acceptObject
);
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/util/adoption-dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,30 @@ export function resolveCiProvider(
return matched ? matched.name : UNKNOWN_DIMENSION;
}

/**
* Where the process is running, as the v2 reconcile body states it.
*
* The same detection telemetry reports as `ci` / `ciProvider`, so the two can
* never disagree about whether a run was CI. Only the spelling differs: the
* service's published schema names providers with hyphens (`github-actions`),
* while telemetry keeps its established underscores, which existing dashboards
* already group by.
*
* `provider` is omitted rather than sentinelled when there is no CI or the
* provider is not recognised. The sentinels exist to keep telemetry aggregates
* countable; on the wire they would be values the service has to know to
* ignore.
*/
export function resolveRunEnvironment(
environment: NodeJS.ProcessEnv = process.env
): { ci: boolean; provider?: string } {
const ci = isContinuousIntegration(environment);
if (!ci) return { ci };
const provider = resolveCiProvider(environment);
if (provider === UNKNOWN_DIMENSION) return { ci };
return { ci, provider: provider.replaceAll("_", "-") };
}

/**
* Node's manifest, which `LANGUAGE_MARKERS` has no entry for.
*
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/test/adoption-dimensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
UNKNOWN_DIMENSION,
isContinuousIntegration,
resolveCiProvider,
resolveRunEnvironment,
resolveLanguageStack,
resolveRepositoryId,
resolveWorkspaceId,
Expand Down Expand Up @@ -193,6 +194,28 @@ describe("the CI dimensions", () => {
});
});

describe("resolveRunEnvironment", () => {
it("states a local run as ci:false with no provider", () => {
expect(resolveRunEnvironment({})).toEqual({ ci: false });
expect(
resolveRunEnvironment({ CI: "false", GITHUB_ACTIONS: "true" })
).toEqual({ ci: false });
});

it("names a recognized provider in the service's hyphenated spelling", () => {
expect(
resolveRunEnvironment({ CI: "true", GITHUB_ACTIONS: "true" })
).toEqual({ ci: true, provider: "github-actions" });
expect(
resolveRunEnvironment({ CI: "1", BITBUCKET_BUILD_NUMBER: "7" })
).toEqual({ ci: true, provider: "bitbucket-pipelines" });
});

it("omits an unrecognized provider rather than sending the sentinel", () => {
expect(resolveRunEnvironment({ CI: "true" })).toEqual({ ci: true });
});
});

describe("languageStack", () => {
it("reports the languages its root manifests evidence", () => {
const root = mkdtempSync(join(tmpdir(), "taskless-stack-"));
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/test/api-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,34 @@ describe("v2 client", () => {
expect(request.headers.get("authorization")).toBe("Bearer tok");
});

it("states the run environment on every reconcile, CI or not", async () => {
// Stubbed both ways so the assertion does not depend on whether the
// suite itself happens to run in CI.
vi.stubEnv("CI", "true");
vi.stubEnv("GITHUB_ACTIONS", "true");
respond(200, { rules: [], unknown: [], entitlement: {} });
await reconcileRules("tok", {
orgId: "o",
repositoryUrl: REPO,
rules: [],
});
expect(await sent().json()).toEqual({
orgId: "o",
repositoryUrl: REPO,
rules: [],
environment: { ci: true, provider: "github-actions" },
});

fetchMock.mockClear();
vi.stubEnv("CI", "");
vi.stubEnv("GITHUB_ACTIONS", "");
respond(200, { rules: [], unknown: [], entitlement: {} });
await reconcileRules("tok", { repositoryUrl: REPO, rules: [] });
const local = (await sent().json()) as { environment?: unknown };
expect(local.environment).toEqual({ ci: false });
vi.unstubAllEnvs();
});

it("puts the repository in the query for a GET, never the path", async () => {
respond(200, SERVED);
await fetchRule("tok", "no-eval-3fa9c21b", { repositoryUrl: REPO });
Expand Down
Loading