Skip to content

Commit 9a61d36

Browse files
committed
feat(hosted-integrations): add github graphql dispatch infra
1 parent f9c688d commit 9a61d36

17 files changed

Lines changed: 1411 additions & 2 deletions

control-plane/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ NestJS is an outer framework, not the business architecture. Domain and applicat
3838
- [Phase 10 Hosted GitHub App Operations Plan](docs/phase-10-hosted-github-app-operations-plan.md)
3939
- [Phase 11 Live E2E Release Gate Plan](docs/phase-11-live-e2e-release-gate-plan.md)
4040
- [Hosted GitHub App Operations Runbook](docs/hosted-github-app-operations.md)
41+
- [GitHub GraphQL Infrastructure](docs/github-graphql-infrastructure.md)
4142
- [Live E2E Release Gate Runbook](docs/live-e2e-release-gate.md)
4243
- [Edge Cases And Failure Modes](docs/edge-cases.md)
4344
- [Security And Privacy Model](docs/security-and-privacy.md)
@@ -91,7 +92,7 @@ control-plane/
9192
docs/
9293
```
9394

94-
Phase 1 provides the workspace scaffold, health feature, config/logger platform packages, and architecture guardrails. Phase 2 adds the dependency-free shared kernel, build metadata plumbing, safe error primitives, typed IDs, time helpers, validation helpers, and stricter shared-kernel guardrails. Phase 3 adds the API safe error boundary, request/correlation ids, request context, and safe request logging. Phase 4 adds optional Postgres persistence, transactions, envelope-encrypted external action content, DB-backed outbox, dead-letter state, and worker claim/retry foundations. Phase 5 adds workspace-bound GitHub App installation setup, desktop client identity, pairing, OAuth claim verification, and repository availability snapshots. Phase 6 adds repository target binding and policy gates. Phase 7 adds the server-side GitHub installation token broker. Phase 8 adds outbox-backed Agent GitHub actions for comments, PR reviews, and check runs without exposing installation tokens to desktop or agents. Phase 9 connects desktop/runtime to the hosted GitHub action path. Phase 10 makes the hosted GitHub App deployment operable. Phase 11 is the live E2E release gate before public beta.
95+
Phase 1 provides the workspace scaffold, health feature, config/logger platform packages, and architecture guardrails. Phase 2 adds the dependency-free shared kernel, build metadata plumbing, safe error primitives, typed IDs, time helpers, validation helpers, and stricter shared-kernel guardrails. Phase 3 adds the API safe error boundary, request/correlation ids, request context, and safe request logging. Phase 4 adds optional Postgres persistence, transactions, envelope-encrypted external action content, DB-backed outbox, dead-letter state, and worker claim/retry foundations. Phase 5 adds workspace-bound GitHub App installation setup, desktop client identity, pairing, OAuth claim verification, and repository availability snapshots. Phase 6 adds repository target binding and policy gates. Phase 7 adds the server-side GitHub installation token broker. Phase 8 adds outbox-backed Agent GitHub actions for comments, PR reviews, and check runs without exposing installation tokens to desktop or agents. Comment and review dispatch uses the GitHub GraphQL adapter; check runs remain on REST. Phase 9 connects desktop/runtime to the hosted GitHub action path. Phase 10 makes the hosted GitHub App deployment operable. Phase 11 is the live E2E release gate before public beta.
9596

9697
`control-plane/` is a nested pnpm workspace on purpose. The desktop app remains the default root workspace, while the optional backend is developed and verified with `pnpm --dir control-plane ...` commands.
9798

control-plane/apps/worker/src/worker-runner.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe("WorkerRunner", () => {
2424
oauthClientIdConfigured: false,
2525
oauthClientSecretConfigured: false,
2626
encryptionMasterKeyConfigured: false,
27+
graphqlEndpointConfigured: false,
2728
privateKeyConfigured: false,
2829
restApiVersionConfigured: false,
2930
webhookSecretConfigured: false,
@@ -166,6 +167,7 @@ function createConfigService(input: {
166167
appIdConfigured: false,
167168
appSlugConfigured: false,
168169
encryptionMasterKeyConfigured: false,
170+
graphqlEndpointConfigured: false,
169171
oauthClientIdConfigured: false,
170172
oauthClientSecretConfigured: false,
171173
privateKeyConfigured: false,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# GitHub GraphQL Infrastructure
2+
3+
## Decision
4+
5+
Agent GitHub actions use a dependency-free GraphQL adapter for comment and review
6+
mutations. The adapter calls GitHub GraphQL with the installation access token issued by
7+
the token broker.
8+
9+
No Octokit dependency is required for the current foundation:
10+
11+
- the token broker already owns GitHub App authentication;
12+
- GraphQL is a single JSON POST endpoint;
13+
- keeping the adapter on `fetch` avoids SDK leakage into application/domain layers;
14+
- architecture guardrails stay simple: GitHub transport details remain in infrastructure.
15+
16+
`CONTROL_PLANE_GITHUB_GRAPHQL_ENDPOINT` is optional and defaults to
17+
`https://api.github.com/graphql`. The override is intended for test environments or
18+
future GitHub Enterprise support.
19+
20+
## Current Routing
21+
22+
The runtime dispatcher is a composite adapter:
23+
24+
- GraphQL:
25+
- `github.issue_comment.create`
26+
- `github.pull_request_comment.create_top_level`
27+
- `github.pull_request_review.create`
28+
- REST:
29+
- `github.check_run.create_or_update`
30+
31+
Check runs stay REST because the Checks API is GitHub App oriented and is already modeled
32+
around REST endpoints and stored check run ids.
33+
34+
## Error And Retry Semantics
35+
36+
GraphQL transport failures during read-only target resolution are retryable.
37+
38+
GraphQL transport failures after a mutation attempt are not retried automatically. GitHub
39+
GraphQL accepts `clientMutationId`, but that field must not be treated as a provider-level
40+
idempotency guarantee for duplicate prevention. Until marker lookup exists for GraphQL
41+
mutations, unknown mutation results are converted to
42+
`CONTROL_PLANE_GITHUB_ACTION_UNKNOWN_RESULT`.
43+
44+
Provider messages and tokens are never copied into safe errors. The adapter maps GitHub
45+
HTTP and GraphQL errors into existing action-level safe error codes.
46+
47+
## Future Action Mapping
48+
49+
Likely GraphQL-first actions:
50+
51+
- create issue: `createIssue`
52+
- create pull request: `createPullRequest`
53+
- create commit on branch: `createCommitOnBranch`
54+
- labels: `addLabelsToLabelable`, `removeLabelsFromLabelable`
55+
- assignees: `addAssigneesToAssignable`, `removeAssigneesFromAssignable`
56+
- PR review decisions: `addPullRequestReview` with `APPROVE` or `REQUEST_CHANGES`
57+
- merge PR: `mergePullRequest`
58+
59+
REST should remain available where GitHub exposes better REST-only semantics or where an
60+
existing REST flow already has stronger idempotency and response handling.

control-plane/packages/features/agent-github-actions/src/application/ports/policies.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface AgentGitHubActionsSettings {
1111
defaultAgentAvatarUrl(): string | undefined;
1212
agentAvatarAllowedOrigins(): readonly string[];
1313
externalContentRetentionDays(): number | undefined;
14+
githubGraphqlEndpoint(): string | undefined;
1415
githubRestApiVersion(): string | undefined;
1516
}
1617

control-plane/packages/features/agent-github-actions/src/application/use-cases/dispatch-github-action.use-case.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ function createHarness(
321321
agentAvatarAllowedOrigins: () => ["https://cdn.example.test"],
322322
defaultAgentAvatarUrl: () => "https://cdn.example.test/default.png",
323323
externalContentRetentionDays: () => 3,
324+
githubGraphqlEndpoint: () => undefined,
324325
githubRestApiVersion: () => "2022-11-28",
325326
},
326327
repository,

control-plane/packages/features/agent-github-actions/src/application/use-cases/request-github-action.use-case.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ function createHarness(input: { policyAllowed?: boolean } = {}) {
161161
agentAvatarAllowedOrigins: () => ["https://cdn.example.test"],
162162
defaultAgentAvatarUrl: () => "https://cdn.example.test/default.png",
163163
externalContentRetentionDays: () => 3,
164+
githubGraphqlEndpoint: () => undefined,
164165
githubRestApiVersion: () => "2022-11-28",
165166
},
166167
{

control-plane/packages/features/agent-github-actions/src/infrastructure/config/config-agent-github-actions.policy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export class ConfigAgentGitHubActionsSettings implements AgentGitHubActionsSetti
3838
return this.configService.getConfig().retention.externalContentDays;
3939
}
4040

41+
public githubGraphqlEndpoint(): string | undefined {
42+
return this.configService.getConfig().github.graphqlEndpoint;
43+
}
44+
4145
public githubRestApiVersion(): string | undefined {
4246
return this.configService.getConfig().github.restApiVersion;
4347
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type {
4+
GitHubActionDispatchResult,
5+
GitHubActionDispatcher,
6+
} from "../../application/ports/github-action-dispatcher.port.js";
7+
import { GitHubCompositeActionDispatcher } from "./github-composite-action.dispatcher.js";
8+
9+
describe("GitHubCompositeActionDispatcher", () => {
10+
it("routes check runs to REST and body actions to GraphQL", async () => {
11+
const graphql = new RecordingDispatcher({
12+
githubDeliveryId: "graphql",
13+
kind: "success",
14+
});
15+
const rest = new RecordingDispatcher({ githubDeliveryId: "rest", kind: "success" });
16+
const dispatcher = new GitHubCompositeActionDispatcher(graphql, rest);
17+
18+
await expect(
19+
dispatcher.dispatch(dispatchInput("github.issue_comment.create")),
20+
).resolves.toMatchObject({
21+
githubDeliveryId: "graphql",
22+
kind: "success",
23+
});
24+
await expect(
25+
dispatcher.dispatch(dispatchInput("github.check_run.create_or_update")),
26+
).resolves.toMatchObject({
27+
githubDeliveryId: "rest",
28+
kind: "success",
29+
});
30+
31+
expect(graphql.actionTypes).toEqual(["github.issue_comment.create"]);
32+
expect(rest.actionTypes).toEqual(["github.check_run.create_or_update"]);
33+
});
34+
});
35+
36+
class RecordingDispatcher implements GitHubActionDispatcher {
37+
public readonly actionTypes: string[] = [];
38+
39+
public constructor(private readonly result: GitHubActionDispatchResult) {}
40+
41+
public dispatch(
42+
input: Parameters<GitHubActionDispatcher["dispatch"]>[0],
43+
): Promise<GitHubActionDispatchResult> {
44+
this.actionTypes.push(input.actionType);
45+
return Promise.resolve(this.result);
46+
}
47+
}
48+
49+
function dispatchInput(
50+
actionType: Parameters<GitHubActionDispatcher["dispatch"]>[0]["actionType"],
51+
): Parameters<GitHubActionDispatcher["dispatch"]>[0] {
52+
return {
53+
actionRequestId: "action-1",
54+
actionType,
55+
payload:
56+
actionType === "github.check_run.create_or_update"
57+
? {
58+
headSha: "a".repeat(40),
59+
name: "Agent Teams / review",
60+
status: "queued",
61+
}
62+
: {
63+
body: "body",
64+
issueNumber: 1,
65+
},
66+
renderedBody: "rendered",
67+
target: { owner: "octo", repo: "repo" },
68+
tokenLease: {
69+
expiresAtMs: 1000,
70+
githubInstallationId: "installation-1",
71+
token: "secret-token",
72+
},
73+
};
74+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type {
2+
GitHubActionDispatchResult,
3+
GitHubActionDispatcher,
4+
} from "../../application/ports/github-action-dispatcher.port.js";
5+
6+
export class GitHubCompositeActionDispatcher implements GitHubActionDispatcher {
7+
public constructor(
8+
private readonly graphqlDispatcher: GitHubActionDispatcher,
9+
private readonly restDispatcher: GitHubActionDispatcher,
10+
) {}
11+
12+
public dispatch(
13+
input: Parameters<GitHubActionDispatcher["dispatch"]>[0],
14+
): Promise<GitHubActionDispatchResult> {
15+
if (input.actionType === "github.check_run.create_or_update") {
16+
return this.restDispatcher.dispatch(input);
17+
}
18+
return this.graphqlDispatcher.dispatch(input);
19+
}
20+
}

0 commit comments

Comments
 (0)