@ogygie/core 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ /**
2
+ * @intent Write by PROPOSAL (DECISIONS.md D17, hoisted into the core by D51):
3
+ * a spec or a decisions file becomes one commit on a branch and a pull
4
+ * request, never a file on the host. Two consumers share it — the MCP server
5
+ * (spec ogygie-mcp B10) and the hosted studio (spec ogygie-hosted-mode) —
6
+ * and a validation fixed on one side and not the other would be a lockstep
7
+ * bug waiting to happen (the motive of D10, applied to writing).
8
+ *
9
+ * Three things live here, in this order:
10
+ * 1. the CONTRACTS — the closed perimeter of proposable paths IS a schema;
11
+ * 2. the GitHub client — six calls, exactly what a proposal needs, over an
12
+ * injected `fetch`;
13
+ * 3. `proposeChange` — the validation `verify-specs` will run on the PR, done
14
+ * first so the caller learns the refusal before anyone reviews it, then
15
+ * the remote steps. The order is fixed: path, rate limit, content, remote
16
+ * state — a forbidden path never costs a remote read.
17
+ *
18
+ * Effects are injected (AGENTS.md principle 5): the client, the clock, the
19
+ * ledger of recent proposals, the sentence that names the proposer. What the
20
+ * caller READ is given as a `RepoAdapter` (D10) to detect a stale page;
21
+ * nothing here touches a disk or a process, and a purity gate can prove it.
22
+ */
23
+ import { z } from "zod";
24
+ import {
25
+ extractDecisionSection,
26
+ findUnknownDecisionReferences,
27
+ parseKnownDecisionIds,
28
+ } from "./decision-refs.js";
29
+ import { OgygieFailure, throwOgygieError } from "./error.schema.js";
30
+ import type { RepoAdapter } from "./repo-adapter.js";
31
+ import { SECTIONS_SPEC_OBLIGATOIRES, parseSpecDocument } from "./spec.schema.js";
32
+
33
+ // ------------------------------------------------------------- contracts
34
+
35
+ /** The two decision files, one GLOBAL D<n> numbering across both. */
36
+ export const DECISION_FILES = ["DECISIONS.md", "DECISIONS.product.md"] as const;
37
+
38
+ /** Where a repo lives on GitHub — what makes it writable by proposal. */
39
+ export const GithubCoordinatesSchema = z.object({
40
+ owner: z.string().min(1),
41
+ repo: z.string().min(1),
42
+ defaultBranch: z.string().min(1).default("main"),
43
+ });
44
+ export type GithubCoordinates = z.infer<typeof GithubCoordinatesSchema>;
45
+
46
+ /**
47
+ * The closed perimeter IS the schema: a spec (never the template), or one of
48
+ * the two decision files. Anything else is refused before any remote read.
49
+ */
50
+ export const ProposablePathSchema = z.union([
51
+ z.string().regex(/^specs\/(?!_)[a-z][a-z0-9]*(-[a-z0-9]+)*\.spec\.md$/),
52
+ z.literal("DECISIONS.md"),
53
+ z.literal("DECISIONS.product.md"),
54
+ ]);
55
+ export type ProposablePath = z.infer<typeof ProposablePathSchema>;
56
+
57
+ export const ProposalSchema = z.object({
58
+ repo: z.string(),
59
+ path: ProposablePathSchema,
60
+ branch: z.string(),
61
+ baseSha: z.string(),
62
+ commitSha: z.string(),
63
+ pullRequestNumber: z.number().int().positive(),
64
+ pullRequestUrl: z.url(),
65
+ /** false when an open pull request was updated instead of created. */
66
+ created: z.boolean(),
67
+ });
68
+ export type Proposal = z.infer<typeof ProposalSchema>;
69
+
70
+ /** Refuses anything outside the perimeter — the first check of every proposal. */
71
+ export function assertProposablePath(path: string): ProposablePath {
72
+ const parsed = ProposablePathSchema.safeParse(path);
73
+ if (!parsed.success) {
74
+ return throwOgygieError({
75
+ code: "WRITE_PATH_FORBIDDEN",
76
+ message: `"${path}" is outside what a proposal may touch.`,
77
+ file: path,
78
+ cause: "the perimeter is specs/<id>.spec.md (not the template), DECISIONS.md, DECISIONS.product.md",
79
+ suggestedFix:
80
+ "Propose a spec or a decisions file; code, manifest, governed files and FAILURES.md are out of reach (D17).",
81
+ });
82
+ }
83
+ return parsed.data;
84
+ }
85
+
86
+ // --------------------------------------------------------- GitHub client
87
+
88
+ export interface GithubWriteClient {
89
+ getBranchHead(branch: string): Promise<string | null>;
90
+ readFileAtRef(path: string, ref: string): Promise<{ content: string; sha: string } | null>;
91
+ createBranch(branch: string, fromSha: string): Promise<void>;
92
+ commitFile(input: {
93
+ branch: string;
94
+ path: string;
95
+ content: string;
96
+ message: string;
97
+ /** Blob sha of the file on that branch — null when the file is new. */
98
+ expectedSha: string | null;
99
+ /** Git author of the commit; the committer stays the token's identity. */
100
+ author?: { name: string; email: string };
101
+ }): Promise<{ commitSha: string }>;
102
+ findOpenPullRequest(branch: string): Promise<{ number: number; url: string } | null>;
103
+ openPullRequest(input: {
104
+ branch: string;
105
+ base: string;
106
+ title: string;
107
+ body: string;
108
+ }): Promise<{ number: number; url: string }>;
109
+ }
110
+
111
+ export interface GithubWriteClientDeps {
112
+ token: string;
113
+ fetchImpl: typeof fetch;
114
+ apiBase?: string;
115
+ /** Sent as `user-agent`, which the GitHub API requires on every call. */
116
+ userAgent?: string;
117
+ /**
118
+ * How the caller names its credential in a refusal — `OGYGIE_MCP_GITHUB_TOKEN`
119
+ * for the MCP server. A 401 that names the variable to check is a fix; one
120
+ * that says "the token" is a riddle.
121
+ */
122
+ credentialHint?: string;
123
+ }
124
+
125
+ export function createGithubWriteClient(
126
+ deps: GithubWriteClientDeps,
127
+ github: GithubCoordinates,
128
+ ): GithubWriteClient {
129
+ const apiBase = deps.apiBase ?? "https://api.github.com";
130
+ const repoUrl = `${apiBase}/repos/${github.owner}/${github.repo}`;
131
+ const credential = deps.credentialHint ?? "the GitHub token";
132
+
133
+ /** One request; 404 is an answer (null) when `allowNotFound`, never a failure. */
134
+ async function request<T>(
135
+ method: string,
136
+ url: string,
137
+ body: unknown,
138
+ allowNotFound: boolean,
139
+ ): Promise<T | null> {
140
+ let response: Response;
141
+ try {
142
+ response = await deps.fetchImpl(url, {
143
+ method,
144
+ headers: {
145
+ authorization: `Bearer ${deps.token}`,
146
+ accept: "application/vnd.github+json",
147
+ "x-github-api-version": "2022-11-28",
148
+ "user-agent": deps.userAgent ?? "ogygie-core",
149
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
150
+ },
151
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
152
+ });
153
+ } catch (thrown) {
154
+ return throwOgygieError({
155
+ code: "GITHUB_UNAVAILABLE",
156
+ message: `GitHub could not be reached for ${github.owner}/${github.repo}.`,
157
+ file: null,
158
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
159
+ suggestedFix: "Retry once the network is back — nothing was written, nothing is queued.",
160
+ });
161
+ }
162
+ if (response.status === 404 && allowNotFound) return null;
163
+ if (!response.ok) {
164
+ return throwOgygieError({
165
+ code: "GITHUB_UNAVAILABLE",
166
+ message: `GitHub answered ${response.status} for ${method} ${url.replace(apiBase, "")}.`,
167
+ file: null,
168
+ cause: `status=${response.status}`,
169
+ suggestedFix:
170
+ response.status === 401 || response.status === 403
171
+ ? `Check ${credential}: it must grant contents and pull requests on this repo.`
172
+ : "Retry later — nothing was written, nothing is queued.",
173
+ });
174
+ }
175
+ return (await response.json()) as T;
176
+ }
177
+
178
+ return {
179
+ async getBranchHead(branch) {
180
+ const ref = await request<{ object: { sha: string } }>(
181
+ "GET",
182
+ `${repoUrl}/git/ref/heads/${encodeURIComponent(branch)}`,
183
+ undefined,
184
+ true,
185
+ );
186
+ return ref?.object.sha ?? null;
187
+ },
188
+ async readFileAtRef(path, ref) {
189
+ const file = await request<{ content: string; sha: string }>(
190
+ "GET",
191
+ `${repoUrl}/contents/${path}?ref=${encodeURIComponent(ref)}`,
192
+ undefined,
193
+ true,
194
+ );
195
+ if (file === null) return null;
196
+ return { content: Buffer.from(file.content, "base64").toString("utf8"), sha: file.sha };
197
+ },
198
+ async createBranch(branch, fromSha) {
199
+ await request("POST", `${repoUrl}/git/refs`, { ref: `refs/heads/${branch}`, sha: fromSha }, false);
200
+ },
201
+ async commitFile(input) {
202
+ const result = await request<{ commit: { sha: string } }>(
203
+ "PUT",
204
+ `${repoUrl}/contents/${input.path}`,
205
+ {
206
+ message: input.message,
207
+ content: Buffer.from(input.content, "utf8").toString("base64"),
208
+ branch: input.branch,
209
+ ...(input.expectedSha === null ? {} : { sha: input.expectedSha }),
210
+ ...(input.author === undefined ? {} : { author: input.author }),
211
+ },
212
+ false,
213
+ );
214
+ return { commitSha: result?.commit.sha ?? "" };
215
+ },
216
+ async findOpenPullRequest(branch) {
217
+ const pulls = await request<{ number: number; html_url: string }[]>(
218
+ "GET",
219
+ `${repoUrl}/pulls?state=open&head=${encodeURIComponent(`${github.owner}:${branch}`)}`,
220
+ undefined,
221
+ false,
222
+ );
223
+ const first = pulls?.[0];
224
+ return first === undefined ? null : { number: first.number, url: first.html_url };
225
+ },
226
+ async openPullRequest(input) {
227
+ const pull = await request<{ number: number; html_url: string }>(
228
+ "POST",
229
+ `${repoUrl}/pulls`,
230
+ { title: input.title, body: input.body, head: input.branch, base: input.base },
231
+ false,
232
+ );
233
+ return { number: pull?.number ?? 0, url: pull?.html_url ?? "" };
234
+ },
235
+ };
236
+ }
237
+
238
+ // --------------------------------------------------------------- proposal
239
+
240
+ /** The repo a proposal targets: its id, its GitHub coordinates, what the caller read. */
241
+ export interface ProposalTarget {
242
+ id: string;
243
+ github: GithubCoordinates;
244
+ /** The repo AS THE CALLER SAW IT — compared to the remote head (REMOTE_CONFLICT). */
245
+ adapter: RepoAdapter;
246
+ }
247
+
248
+ export interface ProposalDeps {
249
+ clientFor: (github: GithubCoordinates) => GithubWriteClient;
250
+ /** The sentence closing the PR body: who proposed, through what. */
251
+ originOf: (path: string) => string;
252
+ now: () => number;
253
+ rateLimitPerHour: number;
254
+ /** Accepted proposals per target id, kept by the caller across calls. */
255
+ recentProposals: Map<string, number[]>;
256
+ /** The branch a path lands on. Default: `mcp/<id>/<path-slug>` (D17). */
257
+ branchFor?: (target: ProposalTarget, path: string) => string;
258
+ /**
259
+ * The git author of the commit — the human who wanted it (D45). Absent, the
260
+ * commit is signed by the token's identity alone, as the MCP server does.
261
+ */
262
+ authorOf?: () => { name: string; email: string } | undefined;
263
+ /**
264
+ * Which head the caller READ, hence which head a stale page is detected
265
+ * against: `defaultBranch` (an MCP client reads a clone of it) or `branch`
266
+ * — the proposal branch itself, when it exists, for a caller that works on
267
+ * it (the hosted studio, D46). Default: `defaultBranch`.
268
+ */
269
+ readFrom?: "defaultBranch" | "branch";
270
+ }
271
+
272
+ export interface ProposalInput {
273
+ path: string;
274
+ content: string;
275
+ message: string;
276
+ }
277
+
278
+ const HOUR_MS = 3_600_000;
279
+ const DECISION_HEADING = /^## D(\d+) — \d{4}-\d{2}-\d{2} — \S/;
280
+ const DECISION_BULLETS = ["**Décision**", "**Motivation**", "**Exclut**"];
281
+
282
+ function specIdOfPath(path: string): string {
283
+ return path.replace(/^specs\//, "").replace(/\.spec\.md$/, "");
284
+ }
285
+
286
+ /** What verify-specs checks on a spec, replayed before proposing. */
287
+ export function assertSpecProposable(path: string, content: string): void {
288
+ const parsed = parseSpecDocument(content);
289
+ const problems = [...parsed.problems];
290
+ if (parsed.frontmatter !== null && parsed.frontmatter.id !== specIdOfPath(path)) {
291
+ problems.push(`id "${parsed.frontmatter.id}" differs from the file name "${specIdOfPath(path)}"`);
292
+ }
293
+ let cursor = 0;
294
+ for (const section of SECTIONS_SPEC_OBLIGATOIRES) {
295
+ const position = parsed.body.indexOf(`\n${section}\n`, cursor);
296
+ if (position === -1) problems.push(`section "${section}" missing or out of order`);
297
+ else cursor = position + section.length;
298
+ }
299
+ if (problems.length > 0) {
300
+ throwOgygieError({
301
+ code: "SPEC_INVALID",
302
+ message: `The proposed spec would fail verify-specs (${problems.length} problem(s)).`,
303
+ file: path,
304
+ cause: problems.join(" ; "),
305
+ suggestedFix:
306
+ "Fix the frontmatter (id, status, depends_on, priority, refs) and keep the six canonical sections in order.",
307
+ });
308
+ }
309
+ if (parsed.frontmatter?.status === "implemented") {
310
+ throwOgygieError({
311
+ code: "SPEC_STATUS_FORBIDDEN",
312
+ message: "A proposal may not declare a spec implemented.",
313
+ file: path,
314
+ cause: "status=implemented",
315
+ suggestedFix:
316
+ "Propose the spec as draft or validated — the repo's manifest and CI say when it is implemented.",
317
+ });
318
+ }
319
+ }
320
+
321
+ /**
322
+ * A decisions file keeps every id it had, numbers stay contiguous across the
323
+ * two files, and a new decision carries its three bullets.
324
+ */
325
+ export function assertDecisionsProposable(
326
+ path: string,
327
+ content: string,
328
+ baseSame: string | null,
329
+ baseOther: string | null,
330
+ ): void {
331
+ const problems: string[] = [];
332
+ for (const line of content.split("\n")) {
333
+ if (line.startsWith("## ") && !DECISION_HEADING.test(line)) {
334
+ problems.push(`heading "${line}" is not "## D<n> — AAAA-MM-JJ — <titre>"`);
335
+ }
336
+ }
337
+ const proposedIds = parseKnownDecisionIds(content);
338
+ const otherIds = parseKnownDecisionIds(baseOther ?? "");
339
+ const previousIds = parseKnownDecisionIds(baseSame ?? "");
340
+ for (const id of previousIds) {
341
+ if (!proposedIds.includes(id)) problems.push(`${id} was declared and is removed or renumbered`);
342
+ }
343
+ const all = [...proposedIds, ...otherIds];
344
+ const seen = new Set<string>();
345
+ for (const id of all) {
346
+ if (seen.has(id)) problems.push(`${id} is declared twice across the decision files`);
347
+ seen.add(id);
348
+ }
349
+ const numbers = [...seen].map((id) => Number.parseInt(id.slice(1), 10)).sort((a, b) => a - b);
350
+ numbers.forEach((value, index) => {
351
+ if (value !== index + 1) problems.push(`numbering has a gap: expected D${index + 1}, found D${value}`);
352
+ });
353
+ for (const id of proposedIds.filter((candidate) => !previousIds.includes(candidate))) {
354
+ const section = extractDecisionSection(content, id) ?? "";
355
+ for (const bullet of DECISION_BULLETS) {
356
+ if (!section.includes(bullet)) problems.push(`${id} lacks its ${bullet} bullet`);
357
+ }
358
+ }
359
+ if (problems.length > 0) {
360
+ throwOgygieError({
361
+ code: "DECISION_INVALID",
362
+ message: `The proposed decisions file breaks the D<n> contract (${problems.length} problem(s)).`,
363
+ file: path,
364
+ cause: problems.join(" ; "),
365
+ suggestedFix:
366
+ "Keep every existing decision, number the new one right after the last across both files, and give it its Décision / Motivation / Exclut bullets.",
367
+ });
368
+ }
369
+ }
370
+
371
+ function assertDecisionReferencesKnown(path: string, content: string, known: readonly string[]): void {
372
+ const unknown = findUnknownDecisionReferences(content, known);
373
+ if (unknown.length > 0) {
374
+ throwOgygieError({
375
+ code: "UNKNOWN_DECISION_REFERENCE",
376
+ message: `The proposal cites ${unknown.length} decision(s) the repo does not declare.`,
377
+ file: path,
378
+ cause: unknown.map((reference) => `${reference.decision} (line ${reference.line})`).join(", "),
379
+ suggestedFix: `Cite only declared decisions (${known.join(", ") || "<none>"}), or propose the decision first.`,
380
+ });
381
+ }
382
+ }
383
+
384
+ function branchSlug(path: string): string {
385
+ return path.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
386
+ }
387
+
388
+ function defaultBranchFor(target: ProposalTarget, path: string): string {
389
+ return `mcp/${target.id}/${branchSlug(path)}`;
390
+ }
391
+
392
+ /** Anything the GitHub client throws outside its contract is still GITHUB_UNAVAILABLE. */
393
+ async function withGithub<T>(action: () => Promise<T>): Promise<T> {
394
+ try {
395
+ return await action();
396
+ } catch (thrown) {
397
+ if (thrown instanceof OgygieFailure) throw thrown;
398
+ return throwOgygieError({
399
+ code: "GITHUB_UNAVAILABLE",
400
+ message: "The GitHub client failed outside its contract.",
401
+ file: null,
402
+ cause: thrown instanceof Error ? thrown.message : String(thrown),
403
+ suggestedFix: "Retry — nothing was written, nothing is queued.",
404
+ });
405
+ }
406
+ }
407
+
408
+ export async function proposeChange(
409
+ target: ProposalTarget,
410
+ deps: ProposalDeps,
411
+ input: ProposalInput,
412
+ ): Promise<Proposal> {
413
+ const path = assertProposablePath(input.path);
414
+ const github = target.github;
415
+ const now = deps.now();
416
+ const recent = (deps.recentProposals.get(target.id) ?? []).filter((at) => now - at < HOUR_MS);
417
+ if (recent.length >= deps.rateLimitPerHour) {
418
+ const retryAfterSeconds = Math.ceil(((recent[0] ?? now) + HOUR_MS - now) / 1000);
419
+ throwOgygieError({
420
+ code: "WRITE_RATE_LIMITED",
421
+ message: `Repo "${target.id}" already received ${recent.length} proposals this hour.`,
422
+ file: null,
423
+ cause: `limit=${deps.rateLimitPerHour}/hour`,
424
+ suggestedFix: `Retry in ${retryAfterSeconds} s, or merge or close the open proposals first.`,
425
+ });
426
+ }
427
+
428
+ const client = deps.clientFor(github);
429
+ const baseSha = await withGithub(() => client.getBranchHead(github.defaultBranch));
430
+ if (baseSha === null) {
431
+ return throwOgygieError({
432
+ code: "GITHUB_UNAVAILABLE",
433
+ message: `Branch "${github.defaultBranch}" does not exist on ${github.owner}/${github.repo}.`,
434
+ file: null,
435
+ cause: "default branch head not found",
436
+ suggestedFix: "Fix `defaultBranch` in the GitHub coordinates of that repo.",
437
+ });
438
+ }
439
+ const baseFiles = new Map<string, string | null>();
440
+ for (const decisionsPath of DECISION_FILES) {
441
+ const file = await withGithub(() => client.readFileAtRef(decisionsPath, baseSha));
442
+ baseFiles.set(decisionsPath, file?.content ?? null);
443
+ }
444
+ if (path.startsWith("specs/")) {
445
+ assertSpecProposable(path, input.content);
446
+ const known = DECISION_FILES.flatMap((file) => parseKnownDecisionIds(baseFiles.get(file) ?? ""));
447
+ assertDecisionReferencesKnown(path, input.content, known);
448
+ } else {
449
+ const other = DECISION_FILES.find((file) => file !== path) ?? DECISION_FILES[1];
450
+ assertDecisionsProposable(path, input.content, baseFiles.get(path) ?? null, baseFiles.get(other) ?? null);
451
+ const known = [...parseKnownDecisionIds(input.content), ...parseKnownDecisionIds(baseFiles.get(other) ?? "")];
452
+ assertDecisionReferencesKnown(path, input.content, known);
453
+ }
454
+
455
+ let branch = (deps.branchFor ?? defaultBranchFor)(target, path);
456
+ let branchHead = await withGithub(() => client.getBranchHead(branch));
457
+ let openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
458
+ if (branchHead !== null && openPull === null) {
459
+ // A branch with no open PR was merged or abandoned: a fresh one, never a
460
+ // commit on top of history the default branch already has.
461
+ branch = `${branch}-${baseSha.slice(0, 7)}`;
462
+ branchHead = await withGithub(() => client.getBranchHead(branch));
463
+ openPull = branchHead === null ? null : await withGithub(() => client.findOpenPullRequest(branch));
464
+ }
465
+ const headOfBranch = branchHead;
466
+ const baseFile = await withGithub(() => client.readFileAtRef(path, baseSha));
467
+ const branchFile =
468
+ headOfBranch === null ? null : await withGithub(() => client.readFileAtRef(path, headOfBranch));
469
+
470
+ // What the caller READ is its adapter; what it must not overwrite is the
471
+ // head it read from — the default branch, or the proposal branch when the
472
+ // caller works on it. When they differ, the caller decided on a stale page.
473
+ const readsBranch = deps.readFrom === "branch" && headOfBranch !== null;
474
+ const compared = readsBranch ? branchFile : baseFile;
475
+ const comparedName = readsBranch ? branch : github.defaultBranch;
476
+ const local = (await target.adapter.exists(path)) ? await target.adapter.readFile(path) : null;
477
+ if ((compared?.content ?? null) !== local) {
478
+ throwOgygieError({
479
+ code: "REMOTE_CONFLICT",
480
+ message: `"${path}" changed on ${comparedName} since it was read.`,
481
+ file: path,
482
+ cause: `head sha=${compared?.sha ?? "<absent>"}, the read copy ${local === null ? "has no such file" : "differs"}`,
483
+ suggestedFix: "Re-read the file (details.headContent is the current head), reapply your change and propose again.",
484
+ details: { headSha: compared?.sha ?? null, headContent: compared?.content ?? null },
485
+ });
486
+ }
487
+
488
+ recent.push(now);
489
+ deps.recentProposals.set(target.id, recent);
490
+ const expectedSha = headOfBranch === null ? (baseFile?.sha ?? null) : (branchFile?.sha ?? null);
491
+ if (headOfBranch === null) {
492
+ await withGithub(() => client.createBranch(branch, baseSha));
493
+ }
494
+ const author = deps.authorOf?.();
495
+ const commit = await withGithub(() =>
496
+ client.commitFile({
497
+ branch,
498
+ path,
499
+ content: input.content,
500
+ message: input.message,
501
+ expectedSha,
502
+ ...(author === undefined ? {} : { author }),
503
+ }),
504
+ );
505
+ const pull =
506
+ openPull ??
507
+ (await withGithub(() =>
508
+ client.openPullRequest({
509
+ branch,
510
+ base: github.defaultBranch,
511
+ title: (input.message.split("\n")[0] ?? input.message).slice(0, 72),
512
+ body: `${input.message}\n\n---\n${deps.originOf(path)}`,
513
+ }),
514
+ ));
515
+ return {
516
+ repo: target.id,
517
+ path,
518
+ branch,
519
+ baseSha,
520
+ commitSha: commit.commitSha,
521
+ pullRequestNumber: pull.number,
522
+ pullRequestUrl: pull.url,
523
+ created: openPull === null,
524
+ };
525
+ }