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
28 changes: 28 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,34 @@
]
}
},
{
"files": ["lib/github/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"regex": "^inngest(?:/|$)",
"message": "GitHub publication primitives must not depend on delivery code."
},
{
"regex": "^@linear/sdk(?:/|$)",
"message": "GitHub publication primitives must not depend on tracker code."
},
{
"regex": "^(?:@openai/codex(?:-sdk)?|@cursor/sdk)(?:/|$)",
"message": "GitHub publication primitives must not depend on agent providers."
},
{
"regex": "^\\.\\./(?:\\.\\./)*(?:linear(?:/|-)|inngest(?:/|$)|triage(?:/|$)|spec(?:/|$)|implementation(?:/|$)|providers(?:/|$))",
"message": "GitHub publication owns commit, push, and pull requests, not tracker, delivery, provider, or domain policy."
}
]
}
]
}
},
{
"files": ["providers/**/*.ts"],
"rules": {
Expand Down
33 changes: 25 additions & 8 deletions docs/contributing/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ but remain disabled until they have their own consumers.
Build a new automation capability from the smallest set of these parts that its
first real flow needs:

| Part | Owns | Must not own |
| ---------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Service primitive | One external system's auth, SDK quirks, pagination, errors, and plain JSON-safe data | Prompts, providers, Inngest, or domain workflow policy |
| Domain operation | Prompt or decision policy, normalized input, strict result validation, and provenance | Tracker lifecycle, delivery retries, or concrete provider wiring |
| Repository/compute primitive | Isolated execution, resumable workspace handles, change inspection, publication, cleanup | Linear, Inngest, Spec, implementation, or other domain policy |
| Projection adapter | Guarded mapping from one domain result to external-system writes | Prompt policy, delivery scheduling, or a shared lifecycle engine |
| Execution consumer | Triggering, fresh reads, claims, durable steps, retries, ordering, coordination, and traces | SDK pagination, prompt rendering, Git commands, or domain choices |
| Event contract | Minimal typed identifiers and stable work identity | A cached replacement for current external truth |
| Part | Owns | Must not own |
| ---------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Service primitive | One external system's auth, SDK quirks, pagination, errors, and plain JSON-safe data | Prompts, providers, Inngest, or domain workflow policy |
| Domain operation | Prompt or decision policy, normalized input, strict result validation, and provenance | Tracker lifecycle, delivery retries, or concrete provider wiring |
| Repository/compute primitive | Isolated execution, resumable workspace handles, change inspection, and cleanup | Publication, Linear, Inngest, Spec, implementation, or domain policy |
| Publication primitive | Approved changes, authenticated commit/push, external artifact identity, and retry recovery | Workspace creation, cleanup, tracker lifecycle, or domain policy |
| Projection adapter | Guarded mapping from one domain result to external-system writes | Prompt policy, delivery scheduling, or a shared lifecycle engine |
| Execution consumer | Triggering, fresh reads, claims, durable steps, retries, ordering, coordination, and traces | SDK pagination, prompt rendering, Git commands, or domain choices |
| Event contract | Minimal typed identifiers and stable work identity | A cached replacement for current external truth |

Dependencies point inward from the execution consumer:

Expand All @@ -83,6 +84,7 @@ execution consumer
-> domain operation -> provider interface
-> projection adapter -> service primitive
-> repository/compute primitive
-> publication primitive
```

Concrete provider adapters, delivery hosts, and external SDK objects stay at
Expand Down Expand Up @@ -139,6 +141,7 @@ real consumers expose the same stable contract.
| `lib/triage/` | Triage prompt, structured decision schema, and provider-independent operation |
| `lib/spec/` | Spec prompt, structured result schema, issue-key artifact validation, and provider-independent operation |
| `lib/repository/` | Grove-backed writable repository leases, safe setup, change inspection, and reset cleanup |
| `lib/github/` | GitHub remote parsing, credential-safe commit and push, exact PR publication, and retry recovery |
| `lib/linear-automation/` | Linear readiness policy, application event contracts, Inngest functions, worker config, and process hosting |
| `lib/skills/` | Packaged-skill installation support |
| `skills/` | Packaged skills installed into target repositories |
Expand Down Expand Up @@ -200,6 +203,20 @@ removes tracked and ordinary untracked work while retaining ignored dependency
caches such as `node_modules`. The module does not commit, push, open pull
requests, update Linear, or choose Spec and implementation policy.

### GitHub publication boundary

The GitHub module accepts a validated `RepositoryRun` and an approved path/status
set. It creates or recognizes one marked commit, pushes one explicit branch
without force, and finds or creates one exact pull request. Recovery observes
the local commit, remote branch SHA, and GitHub PR identity; it does not add a
publication database or retry state machine.

The token stays inside the service and is exposed only to primitive-owned
authenticated Git and REST calls after local run validation. GitHub publication
does not create or clean workspaces, validate Spec content, update Linear, or
depend on Inngest. The durable consumer owns retries and calls repository
cleanup only after publication succeeds.

### Delivery boundary

Inngest functions reload external truth before deciding or projecting. Event
Expand Down
10 changes: 7 additions & 3 deletions docs/contributing/harness-engineering.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ Start with one vertical slice and only the primitives it calls. In order:
1. Normalize the external input through a standalone service primitive.
2. Define one operation-specific input, result, policy, and provenance contract.
3. Add an isolated repository or compute primitive only if the operation must
write, execute, or publish an artifact.
4. Keep tracker projection separate from the operation.
5. Compose the flow with a short durable consumer whose steps match meaningful
write or execute an artifact.
4. Add a separate publication primitive only when approved workspace changes
must become a commit, branch, pull request, or another external artifact.
5. Keep tracker projection separate from the operation.
6. Compose the flow with a short durable consumer whose steps match meaningful
external side effects.

Before handoff, check the dependency direction:
Expand All @@ -72,6 +74,8 @@ Before handoff, check the dependency direction:
- domain operations depend on the provider interface, not a concrete adapter,
and do not import Linear, Inngest, Git, or GitHub code;
- repository primitives do not import domain or tracker policy;
- publication primitives do not create workspaces or import domain, tracker, or
delivery policy;
- projection code does not render prompts or schedule work;
- Inngest consumers reload current truth and coordinate the other parts rather
than implementing them inline;
Expand Down
6 changes: 6 additions & 0 deletions docs/contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ These smokes clean their disposable state on success. Live protocols require
explicit authority, credentials, stop conditions, disposable targets, and
cleanup; they are not routine CI coverage.

GitHub publication uses temporary repositories, a local bare remote, and an
injected HTTP transport in Vitest. This proves commit, push, response-loss, and
PR idempotency without live GitHub access. A live push/PR smoke is reserved for
explicitly authorized protocol verification and must clean its disposable
branch and pull request.

## Verification Commands

During iteration, run the narrowest relevant path, for example:
Expand Down
7 changes: 4 additions & 3 deletions docs/project-intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ such as `/path/to/repo`, `harness.json`, and `.harness/runs/reviews/<run-id>/`.
workflow policy.
- Durable delivery systems may retry, schedule, and observe work, but domain
policy belongs in the independent operation that makes the decision.
- Repository and compute primitives own isolated execution, publication, and
cleanup. They return serializable handles and must not own tracker lifecycle
or domain policy.
- Repository and compute primitives own isolated execution and cleanup.
Publication primitives own authenticated materialization into external
source-control systems. Both return serializable handles and must not own
tracker lifecycle or domain policy.
- External systems that already own queue or lifecycle state remain the source
of truth. Harness must not mirror that state in a second lifecycle store.
- Runtime schemas and exported schemas must stay aligned when either side
Expand Down
139 changes: 139 additions & 0 deletions lib/github/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import { createGitHubPullRequestClient } from "./client.ts";

const TOKEN = "github-secret+/=";
const LOOKUP = {
repository: {
owner: "ferueda",
repository: "harness",
httpsRemote: "https://github.com/ferueda/harness.git",
},
baseBranch: "main",
headBranch: "codex/FER-286",
} as const;

describe("GitHub pull request client", () => {
it("uses exact lookup filters and validates returned pull requests", async () => {
const fetchRequest = vi.fn<typeof fetch>(async () =>
jsonResponse([pullRequestResponse({ state: "closed", mergedAt: "2026-07-23T00:00:00Z" })]),
);
const client = createGitHubPullRequestClient({ token: TOKEN, fetch: fetchRequest });

await expect(client.listPullRequests(LOOKUP)).resolves.toEqual([
{
url: "https://github.com/ferueda/harness/pull/286",
number: 286,
state: "closed",
merged: true,
owner: "ferueda",
repository: "harness",
baseBranch: "main",
headOwner: "ferueda",
headRepository: "harness",
headBranch: "codex/FER-286",
headSha: "a".repeat(40),
},
]);

const [url, options] = fetchRequest.mock.calls[0] ?? [];
expect(String(url)).toContain("state=all");
expect(String(url)).toContain("head=ferueda%3Acodex%2FFER-286");
expect(String(url)).toContain("base=main");
const headers = new Headers(options?.headers);
expect(headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
expect(headers.get("x-github-api-version")).toBe("2026-03-10");
});

it("sends the minimal create payload", async () => {
const fetchRequest = vi.fn<typeof fetch>(async () => jsonResponse(pullRequestResponse()));
const client = createGitHubPullRequestClient({ token: TOKEN, fetch: fetchRequest });

await client.createPullRequest({
...LOOKUP,
title: "Add FER-286 spec",
body: "Generated by Harness",
});

const [, options] = fetchRequest.mock.calls[0] ?? [];
expect(options?.method).toBe("POST");
expect(JSON.parse(String(options?.body))).toEqual({
title: "Add FER-286 spec",
body: "Generated by Harness",
head: "codex/FER-286",
base: "main",
});
});

it("rejects malformed responses", async () => {
const client = createGitHubPullRequestClient({
token: TOKEN,
fetch: async () => jsonResponse([{ number: "not-a-number" }]),
});

await expect(client.listPullRequests(LOOKUP)).rejects.toMatchObject({
code: "invalid-response",
});
});

it("redacts raw and encoded credentials from request failures", async () => {
const encoded = encodeURIComponent(TOKEN);
const client = createGitHubPullRequestClient({
token: TOKEN,
fetch: async () => {
throw new Error(`failed ${TOKEN} ${encoded}`);
},
});

const error = await client.listPullRequests(LOOKUP).catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "github-failed" });
expect(String(error)).not.toContain(TOKEN);
expect(String(error)).not.toContain(encoded);
expect(String(error)).toContain("[REDACTED]");
});

it("normalizes and redacts response body failures", async () => {
const client = createGitHubPullRequestClient({
token: TOKEN,
fetch: async () =>
new Response(
new ReadableStream({
pull(controller) {
controller.error(new Error(`stream failed ${TOKEN}`));
},
}),
),
});

const error = await client.listPullRequests(LOOKUP).catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "github-failed" });
expect(String(error)).not.toContain(TOKEN);
expect(String(error)).toContain("[REDACTED]");
});
});

function pullRequestResponse(
overrides: { state?: "open" | "closed"; mergedAt?: string | null } = {},
) {
return {
html_url: "https://github.com/ferueda/harness/pull/286",
number: 286,
state: overrides.state ?? "open",
merged_at: overrides.mergedAt ?? null,
base: {
ref: "main",
repo: { name: "harness", owner: { login: "ferueda" } },
},
head: {
ref: "codex/FER-286",
sha: "a".repeat(40),
repo: { name: "harness", owner: { login: "ferueda" } },
},
};
}

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
Loading