@brainervirus/workit-core 8.34.0 → 8.35.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.
- package/package.json +1 -1
- package/src/forge/github.ts +38 -0
- package/src/forge/gitlab.ts +48 -0
- package/src/forge/pr-ops.ts +79 -1
- package/src/forge/types.ts +12 -0
package/package.json
CHANGED
package/src/forge/github.ts
CHANGED
|
@@ -29,6 +29,8 @@ export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number:
|
|
|
29
29
|
|
|
30
30
|
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
31
31
|
|
|
32
|
+
export const BRANCH_CHECKS_QUERY = `query($owner: String!, $name: String!, $ref: String!, $contexts: String) { repository(owner: $owner, name: $name) { object(expression: $ref) { ... on Commit { oid statusCheckRollup { contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl } } } } } } } }`;
|
|
33
|
+
|
|
32
34
|
const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
33
35
|
const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
34
36
|
const REPLY_MUTATION = `mutation($thread: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { url } } }`;
|
|
@@ -113,6 +115,12 @@ type GqlPr = {
|
|
|
113
115
|
};
|
|
114
116
|
};
|
|
115
117
|
|
|
118
|
+
type BranchChecksData = {
|
|
119
|
+
repository: {
|
|
120
|
+
object: { oid?: string; statusCheckRollup: { contexts: Page<GqlContext> } | null } | null;
|
|
121
|
+
} | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
116
124
|
type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
|
|
117
125
|
|
|
118
126
|
const prState = (value: string): PrState =>
|
|
@@ -451,6 +459,36 @@ export function createGitHubForge(options: {
|
|
|
451
459
|
return success(status);
|
|
452
460
|
},
|
|
453
461
|
|
|
462
|
+
branchChecks(branch) {
|
|
463
|
+
const contexts: GqlContext[] = [];
|
|
464
|
+
let sha: string | null = null;
|
|
465
|
+
let cursor: string | null = null;
|
|
466
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
467
|
+
const data: ForgeResult<BranchChecksData> = graphql<BranchChecksData>(
|
|
468
|
+
BRANCH_CHECKS_QUERY,
|
|
469
|
+
{ owner, name, ref: `refs/heads/${branch}`, contexts: cursor },
|
|
470
|
+
`checks on ${branch}`,
|
|
471
|
+
);
|
|
472
|
+
if (!data.ok) return data;
|
|
473
|
+
const commit: NonNullable<BranchChecksData["repository"]>["object"] =
|
|
474
|
+
data.data.repository?.object ?? null;
|
|
475
|
+
if (!commit) return failure("not_found", `branch ${branch} was not found`);
|
|
476
|
+
sha = commit.oid ?? sha;
|
|
477
|
+
const rollup: { contexts: Page<GqlContext> } | null = commit.statusCheckRollup;
|
|
478
|
+
if (!rollup) break;
|
|
479
|
+
contexts.push(...rollup.contexts.nodes);
|
|
480
|
+
if (!rollup.contexts.pageInfo.hasNextPage) break;
|
|
481
|
+
cursor = rollup.contexts.pageInfo.endCursor;
|
|
482
|
+
}
|
|
483
|
+
const required = requiredContexts(branch);
|
|
484
|
+
const checks = latestPerName(
|
|
485
|
+
contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
|
|
486
|
+
).map((check) =>
|
|
487
|
+
required ? { ...check, required: required.includes(check.context) } : check,
|
|
488
|
+
);
|
|
489
|
+
return success({ sha, checks, requiredKnown: required !== null });
|
|
490
|
+
},
|
|
491
|
+
|
|
454
492
|
jobLogTail(job, lines) {
|
|
455
493
|
const text = apiText(
|
|
456
494
|
runner,
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -465,6 +465,54 @@ export function createGitLabForge(options: {
|
|
|
465
465
|
return success(status);
|
|
466
466
|
},
|
|
467
467
|
|
|
468
|
+
branchChecks(branch) {
|
|
469
|
+
const tip = baseTip(branch);
|
|
470
|
+
if (!tip) return failure("not_found", `branch ${branch} was not found`);
|
|
471
|
+
const latest = apiJson<
|
|
472
|
+
Array<{ id: number; project_id?: number; status: string; web_url?: string }>
|
|
473
|
+
>(
|
|
474
|
+
runner,
|
|
475
|
+
"glab",
|
|
476
|
+
apiHost,
|
|
477
|
+
api(
|
|
478
|
+
`${project}/pipelines?ref=${encodeURIComponent(branch)}&sha=${tip}&order_by=id&sort=desc&per_page=1`,
|
|
479
|
+
),
|
|
480
|
+
`pipelines on ${branch}`,
|
|
481
|
+
);
|
|
482
|
+
if (!latest.ok) return latest;
|
|
483
|
+
const pipeline = Array.isArray(latest.data) ? latest.data[0] : undefined;
|
|
484
|
+
if (!pipeline) return success({ sha: tip, checks: [], requiredKnown: true });
|
|
485
|
+
const scope = String(pipeline.project_id ?? encodeURIComponent(repo));
|
|
486
|
+
const jobs = pages(
|
|
487
|
+
`projects/${scope}/pipelines/${pipeline.id}/jobs`,
|
|
488
|
+
`pipeline ${pipeline.id} jobs`,
|
|
489
|
+
);
|
|
490
|
+
if (!jobs.ok) return jobs;
|
|
491
|
+
const bridges = pages(
|
|
492
|
+
`projects/${scope}/pipelines/${pipeline.id}/bridges`,
|
|
493
|
+
`pipeline ${pipeline.id} bridges`,
|
|
494
|
+
);
|
|
495
|
+
if (!bridges.ok) return bridges;
|
|
496
|
+
const checks = [
|
|
497
|
+
...(jobs.data.items as GlJob[]).map((job) => checkFromJob(job, pipeline.id, scope)),
|
|
498
|
+
...(bridges.data.items as GlBridge[]).map((bridge) =>
|
|
499
|
+
checkFromBridge(bridge, pipeline.id, scope),
|
|
500
|
+
),
|
|
501
|
+
];
|
|
502
|
+
if (
|
|
503
|
+
stateOf(pipeline.status, false) === "failing" &&
|
|
504
|
+
!checks.some((check) => check.state === "failing")
|
|
505
|
+
)
|
|
506
|
+
checks.push(
|
|
507
|
+
pipelineCheck("failing", pipeline.status, {
|
|
508
|
+
id: pipeline.id,
|
|
509
|
+
project_id: pipeline.project_id ?? 0,
|
|
510
|
+
web_url: pipeline.web_url,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
return success({ sha: tip, checks, requiredKnown: true });
|
|
514
|
+
},
|
|
515
|
+
|
|
468
516
|
jobLogTail(job, lines) {
|
|
469
517
|
const scope = job.scope ?? encodeURIComponent(repo);
|
|
470
518
|
const text = apiText(
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -46,7 +46,14 @@ import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
|
46
46
|
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
47
47
|
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
48
48
|
import type { ResolvedForge } from "./resolve";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
failure,
|
|
51
|
+
success,
|
|
52
|
+
type ForgeCheck,
|
|
53
|
+
type ForgeResult,
|
|
54
|
+
type MergeMethod,
|
|
55
|
+
type PrRef,
|
|
56
|
+
} from "./types";
|
|
50
57
|
|
|
51
58
|
export type Sleep = (ms: number) => Promise<void>;
|
|
52
59
|
|
|
@@ -358,6 +365,8 @@ export type MergeInput = {
|
|
|
358
365
|
expect?: { base: string; branch: string };
|
|
359
366
|
/** Merge without a verdict (`merge: true` only); the reason is recorded. */
|
|
360
367
|
unverified?: { reason: string };
|
|
368
|
+
/** Merge onto a base whose latest CI is red (`--base-red-ok`); the reason is recorded. */
|
|
369
|
+
baseRedOk?: { reason: string };
|
|
361
370
|
/** Squash commit subject and body (`--subject`, `--body`); unset parts use the defaults. */
|
|
362
371
|
subject?: string;
|
|
363
372
|
body?: string;
|
|
@@ -393,6 +402,8 @@ export type MergeOutcome = {
|
|
|
393
402
|
};
|
|
394
403
|
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
395
404
|
unverified: { reason: string; recorded: string } | null;
|
|
405
|
+
/** The base branch's CI at its head: a one-line note when it was not green, and the override used. */
|
|
406
|
+
baseCi: { note: string | null; override: { reason: string } | null };
|
|
396
407
|
grant: { source: AutonomySource };
|
|
397
408
|
/** `skipped`: the implicit default found a reason to keep the branch; the merge is unaffected. */
|
|
398
409
|
deletedBranch: boolean | { error: string } | { skipped: string };
|
|
@@ -454,6 +465,7 @@ export type MergeRefusal = {
|
|
|
454
465
|
| "closed"
|
|
455
466
|
| "protected_branch"
|
|
456
467
|
| "base_mismatch"
|
|
468
|
+
| "base_red"
|
|
457
469
|
| "ledger_tampered"
|
|
458
470
|
| "intent_unrecorded";
|
|
459
471
|
next?: string;
|
|
@@ -632,6 +644,41 @@ function implicitDeleteSkip(
|
|
|
632
644
|
return null;
|
|
633
645
|
}
|
|
634
646
|
|
|
647
|
+
type BaseCi =
|
|
648
|
+
| { ok: true; note: string | null }
|
|
649
|
+
| { ok: false; failing: ForgeCheck[]; sha: string | null; requiredKnown: boolean };
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The base branch head's CI before a merge lands on it: red (a failing check)
|
|
653
|
+
* refuses, pending or unreadable proceeds with a note, green or no checks
|
|
654
|
+
* proceeds quietly. Only a protected target is guarded: a stack's lower
|
|
655
|
+
* branch is its own PR's business.
|
|
656
|
+
*/
|
|
657
|
+
function baseCiState(cwd: string, resolved: ResolvedForge, base: string): BaseCi {
|
|
658
|
+
if (!isProtectedTarget(cwd, base)) return { ok: true, note: null };
|
|
659
|
+
const read = resolved.forge.branchChecks(base);
|
|
660
|
+
if (!read.ok)
|
|
661
|
+
return { ok: true, note: `base ${base} CI not read (${read.error}); merging anyway` };
|
|
662
|
+
// Only checks branch protection requires gate; with the required set
|
|
663
|
+
// unreadable, count hard failures not known to be optional.
|
|
664
|
+
const { requiredKnown } = read.data;
|
|
665
|
+
const failing = read.data.checks.filter(
|
|
666
|
+
(check) =>
|
|
667
|
+
check.state === "failing" &&
|
|
668
|
+
(requiredKnown
|
|
669
|
+
? check.required === true
|
|
670
|
+
: check.required !== false && check.conclusion === "failure"),
|
|
671
|
+
);
|
|
672
|
+
if (failing.length) return { ok: false, failing, sha: read.data.sha, requiredKnown };
|
|
673
|
+
const pending = read.data.checks.filter((check) => check.state === "pending");
|
|
674
|
+
return {
|
|
675
|
+
ok: true,
|
|
676
|
+
note: pending.length
|
|
677
|
+
? `base ${base} CI is still running (${pending.map((check) => check.name).join(", ")}); merging without waiting`
|
|
678
|
+
: null,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
635
682
|
export async function mergePullRequest(
|
|
636
683
|
cwd: string,
|
|
637
684
|
resolved: ResolvedForge,
|
|
@@ -650,6 +697,13 @@ export async function mergePullRequest(
|
|
|
650
697
|
{ reason: "unverified_refused" },
|
|
651
698
|
);
|
|
652
699
|
|
|
700
|
+
const baseRed = input.baseRedOk ?? null;
|
|
701
|
+
if (baseRed && !baseRed.reason.trim())
|
|
702
|
+
return failure(
|
|
703
|
+
"invalid_input",
|
|
704
|
+
"--base-red-ok needs --reason <why merging onto a red base is fine>",
|
|
705
|
+
);
|
|
706
|
+
|
|
653
707
|
const number = selectPr(cwd, resolved, { pr: input.pr });
|
|
654
708
|
if (!number.ok) return number;
|
|
655
709
|
const status = resolved.forge.prStatus(number.data);
|
|
@@ -789,6 +843,20 @@ export async function mergePullRequest(
|
|
|
789
843
|
);
|
|
790
844
|
}
|
|
791
845
|
|
|
846
|
+
const baseCi = baseCiState(cwd, resolved, doc.base);
|
|
847
|
+
let baseRedOverride: { reason: string } | null = null;
|
|
848
|
+
if (!baseCi.ok) {
|
|
849
|
+
const names = baseCi.failing.map((failed) => failed.name).join(", ");
|
|
850
|
+
const url = baseCi.failing.find((failed) => failed.url)?.url ?? null;
|
|
851
|
+
if (!baseRed)
|
|
852
|
+
return refuse(
|
|
853
|
+
`base_red: ${doc.base} at ${baseCi.sha?.slice(0, 12) ?? "its head"} has failing CI (${names}${baseCi.requiredKnown ? "" : "; the required checks could not be read, so every failed check counts"})${url ? `: ${url}` : ""}; merging ${label} onto it would land on a red base`,
|
|
854
|
+
`fix ${doc.base} first, or, if the user accepts it: workit pr merge${input.pr ? ` --pr ${doc.number}` : ""} --base-red-ok --reason "<why>"`,
|
|
855
|
+
{ reason: "base_red", blockers: baseCi.failing.map((failed) => failed.name) },
|
|
856
|
+
);
|
|
857
|
+
baseRedOverride = baseRed;
|
|
858
|
+
}
|
|
859
|
+
|
|
792
860
|
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
793
861
|
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
794
862
|
// The intent goes in before the forge write: a merge call that errors after
|
|
@@ -806,6 +874,7 @@ export async function mergePullRequest(
|
|
|
806
874
|
review: verdict.review,
|
|
807
875
|
lane: verdict.lane,
|
|
808
876
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
877
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
809
878
|
grant: grant.source,
|
|
810
879
|
});
|
|
811
880
|
if (!intent.ok)
|
|
@@ -841,6 +910,7 @@ export async function mergePullRequest(
|
|
|
841
910
|
review: verdict.review,
|
|
842
911
|
lane: verdict.lane,
|
|
843
912
|
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
913
|
+
...(baseRedOverride ? { baseRedOk: baseRedOverride.reason } : {}),
|
|
844
914
|
grant: grant.source,
|
|
845
915
|
intent: intentId,
|
|
846
916
|
verified: false,
|
|
@@ -909,6 +979,14 @@ export async function mergePullRequest(
|
|
|
909
979
|
mergeSha: merged.data.mergeSha,
|
|
910
980
|
verdict,
|
|
911
981
|
unverified,
|
|
982
|
+
baseCi: {
|
|
983
|
+
note: baseRedOverride
|
|
984
|
+
? `base ${doc.base} CI is red; merged anyway (--base-red-ok: ${baseRedOverride.reason})`
|
|
985
|
+
: baseCi.ok
|
|
986
|
+
? baseCi.note
|
|
987
|
+
: null,
|
|
988
|
+
override: baseRedOverride,
|
|
989
|
+
},
|
|
912
990
|
grant: { source: grant.source },
|
|
913
991
|
deletedBranch,
|
|
914
992
|
recorded,
|
package/src/forge/types.ts
CHANGED
|
@@ -110,6 +110,16 @@ export type ForgePrStatus = {
|
|
|
110
110
|
truncated: boolean;
|
|
111
111
|
};
|
|
112
112
|
|
|
113
|
+
/** The checks on the latest commit of a branch (the merge guard reads the base's). */
|
|
114
|
+
export type RefChecks = {
|
|
115
|
+
/** The commit the checks belong to; null when the forge did not say. */
|
|
116
|
+
sha: string | null;
|
|
117
|
+
/** `required` is set from branch protection when `requiredKnown`; else it is null on GitHub. */
|
|
118
|
+
checks: ForgeCheck[];
|
|
119
|
+
/** True when the branch's required checks could be read (GitLab gates on the whole pipeline). */
|
|
120
|
+
requiredKnown: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
113
123
|
export type JobRef = { jobId: number; scope: string | null };
|
|
114
124
|
|
|
115
125
|
export type RerunTarget =
|
|
@@ -196,6 +206,8 @@ export interface Forge {
|
|
|
196
206
|
repoInfo(repo: string): ForgeResult<RepoInfo>;
|
|
197
207
|
findPr(head: string, match: PrMatch): ForgeResult<PrRef | null>;
|
|
198
208
|
prStatus(n: number): ForgeResult<ForgePrStatus>;
|
|
209
|
+
/** The checks on the head commit of branch `branch`; empty when CI has not reported for it. */
|
|
210
|
+
branchChecks(branch: string): ForgeResult<RefChecks>;
|
|
199
211
|
jobLogTail(job: JobRef, lines: number): ForgeResult<string[]>;
|
|
200
212
|
rerun(target: RerunTarget): ForgeResult<void>;
|
|
201
213
|
/** Open a PR/MR; `headSha` is the head the forge reports right after creation. */
|