Skip to content

Commit 6ff85ee

Browse files
committed
πŸ”€ Create or bring up to date one pull request for the published branch
`<PullRequest>` is an upsert over one explicit pull-request identity. Without a number it asks for a pull request from this head to this base β€” creating one, or adopting the compatible one an interrupted attempt already made. With a number it asks for that exact pull request, and brings its title, body, draft state and base to what the invocation says; when they already say it, the no-op is recorded as the shared adoption rather than as an action of its own. The two modes are two resources, so they are two natural-key shapes β€” a create key of Repository plus branch pair, an update key of Repository plus number β€” while the complete fingerprint still covers every input, `number` included. The shared state machine is untouched: an update that has work to do is the requested completion being absent, with the existing pull request retained as the pre-state a performed update describes itself against. A performed update whose pre-state already agreed with every requested field is refused, because that record describes work with nothing to do. It never pushes. The run must already hold its own successful `<Git.Push>` result for this exact Repository identity, head branch, destination ref and commit, before a creation and before an update alike, and the head is never rewritten. Updates are one REST field change plus, when draft state differs, one GraphQL draft transition, each issued at most once and followed by exactly one observation that decides the outcome. A rejected mutation, a partial multi-call update and an unreadable answer are unavailability: nothing repeats inside the attempt, and a later attempt finishes only what is left. Every payload the adapter takes in β€” a listing candidate, a numbered lookup, a creation's answer, an update's final observation β€” goes through one reading that carries the eleven snapshot facts plus the repositories the head and base live in. Nothing is counted before it is read, so a listing of members this adapter cannot understand is unavailability rather than an ambiguity or a conflict; a numbered answer becomes a closed conflict only once it has proven it is this pull request; a fork candidate is outside the supported direct same-repository key and is excluded before anything is counted; and a creation naming another repository publishes no completion. Repository names compare without case. Two defects the review reproduced earlier are closed. The Push admission required only a matching natural key, so a record whose internally valid inputs and result named another repository and branch authorized this one; it now requires the key, the inputs, the parsed record and the request to describe one publication. And a page walk that could not be completed β€” an off-origin, malformed or otherwise unfollowable next relation β€” reported the candidates it had collected; it is now unavailability, which is never absence. The first adapter works over github.com, selected from the private retained locator and credentialed from GH_TOKEN then GITHUB_TOKEN. The locator, the endpoint, the credential and every raw response stay in the per-invocation provider closure; routing middleware sees the frozen JSON request and the journal holds the normalized record alone.
1 parent a330e25 commit 6ff85ee

21 files changed

Lines changed: 6047 additions & 48 deletions

β€Žarchitecture.mdβ€Ž

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ absence and refuses conflicting or permanently ambiguous state. Temporary
989989
provider unavailability may be retried or suspended by explicit middleware; it
990990
never authorizes a duplicate effect while completion is unknown.
991991

992-
Git-host mutations β€” Git push, pull-request creation and issue creation β€” share
992+
Git-host mutations β€” Git push, pull-request upsert and issue creation β€” share
993993
one provider-neutral reconciliation of that boundary, so a new Git-host effect
994994
kind supplies a request and a provider rather than a replay policy of its own.
995995
Prompt is not one of them: it keeps its own Agent-provider contract. A live
@@ -2306,7 +2306,7 @@ Status is measured against main.
23062306
| caller-owned storage transaction | publishes several changes, including journal events, in one transaction nothing else enlists in | built on main |
23072307
| live durable-operation coordinator | explicitly coordinates structured live execution with existing Yield publication while leaving replay and callback effects unchanged | built on the #365 stack |
23082308
| Workspace coordination API | fails closed by default; replaceable context routes only a one-use provider selection, while the selected provider directly invokes an execution-owned credentialed capability for execution, publication and failure activation | built on the #365 stack; the Deno provider installs an adapter-private atomic handler |
2309-
| shared Git-host reconciliation | reconciles Git push, pull-request creation and issue creation through one provider-neutral state machine journaled as `git_host_effect` β€” Prompt is not a consumer and keeps its own Agent-provider contract: run and expansion identity derived by the engine, observation before mutation, adoption of a proven compatible completion, one performance of a proven absence, refusal of conflict and permanent ambiguity, temporary unavailability as its own ordinary failure, and a provider free to support only some kinds; routing is one request-only contextual operation carrying no completion authority, and only an answer the invocation's own terminal accepted can author a journaled outcome | built on the #297 stack; `Git.Push` is its first consumer, built on the #370 stack, while pull request and issue components remain unbuilt (#295, #296) |
2309+
| shared Git-host reconciliation | reconciles Git push, pull-request upsert and issue creation through one provider-neutral state machine journaled as `git_host_effect` β€” Prompt is not a consumer and keeps its own Agent-provider contract: run and expansion identity derived by the engine, observation before mutation, adoption of a proven compatible completion, one performance of a proven absence, refusal of conflict and permanent ambiguity, temporary unavailability as its own ordinary failure, and a provider free to support only some kinds; routing is one request-only contextual operation carrying no completion authority, and only an answer the invocation's own terminal accepted can author a journaled outcome | built on the #297 stack; `Git.Push` is its first consumer, built on the #370 stack, and `<PullRequest>` its second β€” a create-or-update upsert β€” built on the #295 stack, while the issue component remains unbuilt (#296) |
23102310
| explicit WorkflowRun journal route | binds one already-filtered publication to one exact active transaction and otherwise uses ordinary serialized journal storage | built on the #365 stack |
23112311
| `API.Service` / `startService()` | creates an authenticated, supervised loopback service attachment through a provider-neutral operation | built on main |
23122312
| foreground command routing and retention | forwards a child's channels live, captures stdout for a `<Capture as>` region, and retains output only when the host asked for a record | built on the #441 stack |
@@ -2324,6 +2324,7 @@ Status is measured against main.
23242324
| `<Repository>` / `<Worktree>` / `<Dir>` composition | names a Git repository and its linked checkouts inside the run-owned Workspace, installs each as contextual working directory, and retains creation identity beside the retained Git bytes | built on the #293 stack, Deno provider only |
23252325
| transactional Git effects (`Git.Switch` / `Git.Add` / `Git.Commit`) | publish local Git mutations with their journal result; the enclosing Repository and the contextual working directory select which retained checkout one runs in, and neither observation carries authority β€” the observed record is compared with the retained row and the directory with the checkouts that row holds, so a failure of authority, of retained state or of an unrecognized native condition fails the run instead of publishing a result | built on the #294 stack, Deno provider only |
23262326
| `Git.Push` | publishes the selected checkout's exact current named branch and commit to the same branch on the retained Repository's canonical `origin`, reconciled through the shared Git-host state machine rather than through a Workspace transaction: no props and no component result, no force, no upstream mutation and no implicit staging or committing; the durable request and record carry the Repository's filtered identity without its checkout path, and the transport runs in a provider-owned isolated control repository reading the checkout's objects through an object-source attachment whose alternates chain and object tree are proven contained before the first remote observation, aimed at the exact private retained locator | built on the #370 stack, Deno provider only |
2327+
| `<PullRequest>` | upserts one pull request of the selected checkout's current named branch, reconciled through the shared Git-host state machine: a required `title`, an optional positive-integer `number`, an optional `base` defaulting to the Repository's retained initial branch, an optional `draft`, and the rendered content as the body; it renders nothing and returns stable evidence through `as` β€” the filtered Repository identity, the provider's own stable pull-request identity, number, URL, open state, and the head and base SHAs of the snapshot it finished at. Without a number it creates one pull request for the head/base pair or adopts the compatible one an interrupted attempt left; with a number it brings that exact pull request's title, body, draft state and base to what the request says, records a no-op when they already match, and refuses a number belonging to another repository, opened from another head, or no longer open. It never pushes, never rewrites a head, and never reopens, merges or comments. The run must already hold its own successful `Git.Push` result for that exact Repository identity, head branch, destination ref and commit β€” proven by a scan that requires the record's natural key, inputs and result to describe one publication β€” and a missing, conflicting or unreadable one fails locally before the Git host is observed; the first adapter works over `github.com` on REST plus the two GraphQL draft transitions, selected from the private retained locator, credentialed from `GH_TOKEN` then `GITHUB_TOKEN`, issuing each required mutation at most once per attempt and deciding the outcome by one observation, with the locator, endpoint, credential and payload confined to the per-invocation provider closure | built on the #295 stack, Deno provider only |
23272328
| workflow lifecycle inspection and control | reads status/list/history without advancing a run, enforces the executor lock, refuses live cancellation, cancels non-live runs under that lock and deletes retained state | built on the #367 stack |
23282329
| historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack |
23292330
| history fork | creates a new run from one compatible checkpoint and retained Workspace root | defined in `specs/workflow-workspace-spec.md`, unbuilt (#368) |

β€Žpackages/workflow/mod.tsβ€Ž

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export {
7373
GitCompositionProviderError,
7474
GitOperationError,
7575
GitOperationProtocolError,
76+
PullRequestAuthorityError,
7677
RepositoryCompositionError,
7778
RepositoryCompositionProtocolError,
7879
RepositoryCompositionProviderError,
@@ -81,6 +82,7 @@ export {
8182
} from "./src/composition/errors.ts";
8283
export type {
8384
GitFailureReason,
85+
PullRequestAuthorityReason,
8486
RepositoryFailureReason,
8587
WorktreeFailureReason,
8688
} from "./src/composition/errors.ts";
@@ -159,6 +161,45 @@ export type {
159161
GitPushRequest,
160162
GitPushResult,
161163
} from "./src/composition/git-push-records.ts";
164+
export {
165+
OPEN,
166+
parsePullRequestInputs,
167+
parsePullRequestNaturalKey,
168+
parsePullRequestObservations,
169+
parsePullRequestPreState,
170+
parsePullRequestRecord,
171+
parsePullRequestResult,
172+
parsePullRequestSnapshot,
173+
PULL_REQUEST,
174+
pullRequestAgrees,
175+
pullRequestMode,
176+
pullRequestInputsJson,
177+
pullRequestNaturalKey,
178+
pullRequestNaturalKeyJson,
179+
pullRequestNumber,
180+
pullRequestObservationsJson,
181+
pullRequestPreStateJson,
182+
pullRequestResultJson,
183+
pullRequestResultOf,
184+
pullRequestSnapshotJson,
185+
sameNaturalKey,
186+
samePullRequestIdentity,
187+
} from "./src/composition/pull-request-records.ts";
188+
export type {
189+
PullRequestCreateKey,
190+
PullRequestExpectation,
191+
PullRequestInputs,
192+
PullRequestMode,
193+
PullRequestNaturalKey,
194+
PullRequestObservations,
195+
PullRequestOutcome,
196+
PullRequestPreState,
197+
PullRequestRequest,
198+
PullRequestResult,
199+
PullRequestSnapshot,
200+
PullRequestUpdateKey,
201+
} from "./src/composition/pull-request-records.ts";
202+
export { admitPushEvidence } from "./src/composition/push-evidence.ts";
162203
export { useCompositionComponents } from "./src/composition/installation.ts";
163204

164205
export { GIT_HOST_API, GitHost } from "./src/git-host/api.ts";
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* `<PullRequest>` β€” one pull request for the branch this run published, created
3+
* or brought up to date (specs/workflow-workspace-spec.md Β§7.5).
4+
*
5+
* ```md
6+
* <Repository name="project" url={props.repository}>
7+
* <Git.Switch branch="release/1.4" />
8+
* <Git.Add paths="release/notes.md" />
9+
* <Git.Commit message="Prepare 1.4" as="commit" />
10+
* <Git.Push />
11+
* <PullRequest number={props.pullRequestNumber} title="Prepare 1.4" as="pullRequest">
12+
* Prepared from commit {commit}.
13+
* </PullRequest>
14+
* </Repository>
15+
* ```
16+
*
17+
* `title` is required, `number` defaults to none, `base` defaults to the
18+
* Repository's recorded initial branch, `draft` defaults to false, and the
19+
* rendered content is the body. The head is the branch the selected checkout is
20+
* on β€” decided the way Β§7.1 decides every Git operation's place, by the
21+
* enclosing `<Repository>` and the contextual working directory β€” so there is no
22+
* `head`, `repository`, `remote` or `provider` prop to say it a second way.
23+
*
24+
* ## An upsert over one explicit identity
25+
*
26+
* Without a number the document is asking for *a pull request from this head to
27+
* this base*: one is created, or the compatible one an interrupted earlier
28+
* attempt already created is adopted. With a number it is asking for *that pull
29+
* request*: its title, body, draft state and base are brought to what this
30+
* invocation says, and if they already say it, nothing is performed and the
31+
* no-op is recorded.
32+
*
33+
* Which of the two it is, is the document's own word rather than a search. A
34+
* number that names a pull request belonging to another Repository, opened from
35+
* another head, or no longer open is a conflict β€” never a rewrite onto
36+
* unrelated state. The head is never rewritten either: an update moves the four
37+
* fields it names and nothing else.
38+
*
39+
* ## It publishes nothing
40+
*
41+
* A pull request is a statement about a branch that already exists on the Git
42+
* host, and this component never makes one exist. `<Git.Push />` is written
43+
* explicitly, and the run must already hold that Push's own successful result
44+
* for this exact Repository, head branch, destination ref and commit β€” before a
45+
* creation and before an update alike. Direct remote observation is not a
46+
* substitute: a branch this run finds at the host may have been put there by
47+
* anything, and opening or repointing a pull request for it would be publishing
48+
* somebody else's work under this run's name.
49+
*
50+
* ## What it produces
51+
*
52+
* Stable evidence of what this effect settled on, bound through `as`: the
53+
* filtered Repository identity, the provider's own stable identity for the pull
54+
* request, its number, its URL, its open state, and the head and base commits
55+
* of the snapshot the reconciliation finished at. Reviews, comments, checks,
56+
* labels, merge state and later edits are separate reads rather than freshness
57+
* smuggled into the result β€” a replayed run hands back exactly what the run
58+
* recorded, whatever the pull request looks like now.
59+
*
60+
* ## Failure
61+
*
62+
* Content expands first and completely, so a body that failed to render stops
63+
* everything this component owns before a Repository is even observed. After
64+
* that, a missing Repository context, a checkout whose HEAD names no branch and
65+
* missing, conflicting or unreadable Push evidence are all decided locally,
66+
* before the Git host exists in the story, and each fails the run rather than
67+
* printing: a later sibling must not run as though a pull request had been
68+
* opened. What the Git host itself answers β€” a conflict, an ambiguity, an
69+
* unavailability β€” travels under Β§10.2's shared vocabulary.
70+
*/
71+
72+
import { cwd } from "@executablemd/runtime";
73+
import { content, hasContent } from "@executablemd/core";
74+
import type { PropsSchema, ReturnsSchema } from "@executablemd/core";
75+
import type { Operation } from "effection";
76+
import type { Json } from "@executablemd/durable-streams";
77+
import { GitComposition } from "../git-api.ts";
78+
import { currentRepository } from "../context.ts";
79+
import { PullRequestAuthorityError } from "../errors.ts";
80+
import { pullRequestResultJson } from "../pull-request-records.ts";
81+
82+
/** The component name, as a document writes it and as a refusal names it. */
83+
export const PULL_REQUEST_ELEMENT = "<PullRequest>";
84+
85+
export const props: PropsSchema = {
86+
type: "object",
87+
properties: {
88+
/** The pull request to bring up to date. Absent asks for one to exist. */
89+
number: { type: "integer", minimum: 1 },
90+
title: { type: "string", minLength: 1 },
91+
base: { type: "string", minLength: 1 },
92+
draft: { type: "boolean", default: false },
93+
},
94+
required: ["title"],
95+
additionalProperties: false,
96+
};
97+
98+
/**
99+
* The evidence a document binds, exactly.
100+
*
101+
* Closed, because this is the boundary a document reads the record through: a
102+
* member arriving here that the contract does not declare would be a provider
103+
* detail reaching a binding, and a missing one would be a document reading
104+
* `undefined` where a number was promised.
105+
*/
106+
export const returns: ReturnsSchema = {
107+
type: "object",
108+
properties: {
109+
repository: {
110+
type: "object",
111+
properties: {
112+
name: { type: "string", minLength: 1 },
113+
locatorFingerprint: { type: "string", pattern: "^[0-9a-f]{64}$" },
114+
requestedBase: { type: ["string", "null"] },
115+
creationCommit: { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" },
116+
primaryBranch: { type: "string", minLength: 1 },
117+
objectFormat: { enum: ["sha1", "sha256"] },
118+
},
119+
required: [
120+
"name",
121+
"locatorFingerprint",
122+
"requestedBase",
123+
"creationCommit",
124+
"primaryBranch",
125+
"objectFormat",
126+
],
127+
additionalProperties: false,
128+
},
129+
providerId: { type: "string", minLength: 1 },
130+
number: { type: "integer", minimum: 1 },
131+
url: { type: "string", minLength: 1 },
132+
state: { const: "open" },
133+
headSha: { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" },
134+
baseSha: { type: "string", pattern: "^([0-9a-f]{40}|[0-9a-f]{64})$" },
135+
},
136+
required: ["repository", "providerId", "number", "url", "state", "headSha", "baseSha"],
137+
additionalProperties: false,
138+
};
139+
140+
export default function* PullRequest(props: Record<string, Json>): Operation<Json> {
141+
// Before anything of this element's own exists β€” before a Repository is
142+
// observed, before a checkout is selected, before retained Push evidence is
143+
// read and long before a Git host is contacted. The body is what the pull
144+
// request would say, and a body that never finished rendering must not become
145+
// one that was published.
146+
const body = (yield* hasContent()) ? yield* content() : "";
147+
148+
const repository = yield* currentRepository();
149+
if (repository === undefined) {
150+
throw new PullRequestAuthorityError(
151+
"no-repository-context",
152+
"it is written outside a lexical <Repository>, so there is no repository in scope for a " +
153+
"pull request to be opened in.",
154+
);
155+
}
156+
157+
const outcome = yield* GitComposition.operations.upsertPullRequest({
158+
repository,
159+
workingDirectory: yield* cwd(),
160+
// Normalized once, here: absence is `null` from this point on, because the
161+
// durable request is JSON and a member that is sometimes missing would be a
162+
// second shape rather than a value.
163+
number: typeof props.number === "number" ? props.number : null,
164+
title: typeof props.title === "string" ? props.title : "",
165+
// Verbatim. What a document wrote is the pull request's body, and trimming
166+
// or reflowing it here would publish something nobody authored.
167+
body,
168+
draft: props.draft === true,
169+
// The Repository's own initial branch, retained when it was created, rather
170+
// than whatever its default branch is at the host right now.
171+
base:
172+
typeof props.base === "string" && props.base !== "" ? props.base : repository.primaryBranch,
173+
});
174+
return pullRequestResultJson(outcome.result);
175+
}

β€Žpackages/workflow/src/composition/errors.tsβ€Ž

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,48 @@ export class GitOperationAuthorityError extends StaleInputError {
187187
}
188188
}
189189

190+
/**
191+
* A word from the fixed vocabulary a `<PullRequest>` authority refusal speaks.
192+
*
193+
* Every one of them is decided locally, before a Git host is observed. A pull
194+
* request is a public statement about a branch that exists somewhere else, and
195+
* the run has to be able to prove it put that branch there before it makes one.
196+
*/
197+
export type PullRequestAuthorityReason =
198+
| "no-repository-context"
199+
| "unnamed-branch"
200+
| "missing-push-evidence"
201+
| "conflicting-push-evidence"
202+
| "unreadable-push-evidence";
203+
204+
/**
205+
* `<PullRequest>` is not authorized by what this run retained.
206+
*
207+
* A `StaleInputError`, on the same terms as {@link GitOperationAuthorityError}:
208+
* a document cannot avoid a Repository context that was replaced or a retained
209+
* Push result that stopped being readable, and later siblings must not run as
210+
* though a pull request had been opened. None of these is a Git-host answer β€”
211+
* every one of them is decided before anything is observed β€” so none of them is
212+
* printable and none of them is journaled as an effect outcome.
213+
*
214+
* The sentence names the category and, where there is one, the remedy. It
215+
* quotes no journal content, no retained record and nothing a caller presented:
216+
* the evidence this refuses to read is exactly the evidence it must not repeat.
217+
*/
218+
export class PullRequestAuthorityError extends StaleInputError {
219+
override name = "PullRequestAuthorityError";
220+
221+
readonly reason: PullRequestAuthorityReason;
222+
223+
constructor(reason: PullRequestAuthorityReason, sentence: string) {
224+
super(
225+
`<PullRequest> is not authorized against this run's retained state: ${sentence} Nothing ` +
226+
"was observed at the Git host, and no pull request was created.",
227+
);
228+
this.reason = reason;
229+
}
230+
}
231+
190232
/**
191233
* Native Git failed in a way this provider does not recognize.
192234
*

0 commit comments

Comments
Β (0)