@henryqw/pi-pr 4.0.2 → 4.0.4
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/README.md +12 -10
- package/extensions/pr-ci.ts +0 -1
- package/extensions/pr-command.ts +36 -27
- package/extensions/pr-comment-sweep.ts +0 -27
- package/extensions/pr-create.ts +76 -230
- package/extensions/pr-execution.ts +28 -163
- package/extensions/pr-github.ts +339 -193
- package/extensions/pr-merge.ts +24 -111
- package/extensions/pr-routing.ts +6 -2
- package/extensions/pr-ui.ts +1 -2
- package/extensions/pr-update-branch.ts +28 -90
- package/extensions/pr.ts +36 -58
- package/package.json +6 -2
- package/skills/pi-pr-create/SKILL.md +5 -5
package/extensions/pr-github.ts
CHANGED
|
@@ -15,7 +15,6 @@ import type {
|
|
|
15
15
|
PullRequestLifecycle,
|
|
16
16
|
PullRequestTarget,
|
|
17
17
|
ReviewReadiness,
|
|
18
|
-
PolicyReadiness,
|
|
19
18
|
} from "./pr-routing.ts";
|
|
20
19
|
|
|
21
20
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
@@ -27,7 +26,6 @@ const PR_PUBLICATION_FIELDS = "number,url,state,baseRefName,headRefName,headRefO
|
|
|
27
26
|
const PR_DISCOVERY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!,$endCursor:String){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name associatedPullRequests(first:100,after:$endCursor){totalCount edges{cursor node{__typename number url state baseRepository{nameWithOwner}headRepository{nameWithOwner}headRefName headRefOid}}pageInfo{hasNextPage startCursor endCursor}}}}}";
|
|
28
27
|
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
29
28
|
const BASE_REF_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name target{oid}}}}";
|
|
30
|
-
const BASE_BRANCH_POLICY_QUERY = "query($owner:String!,$name:String!,$qualifiedName:String!){repository(owner:$owner,name:$name){nameWithOwner ref(qualifiedName:$qualifiedName){name branchProtectionRule{requiresStrictStatusChecks}}}}";
|
|
31
29
|
const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
32
30
|
const FAILED_CHECK_STATES = new Set([
|
|
33
31
|
"ACTION_REQUIRED",
|
|
@@ -60,7 +58,6 @@ const MERGE_STATE_VALUES = new Set([
|
|
|
60
58
|
"UNSTABLE",
|
|
61
59
|
]);
|
|
62
60
|
const REVIEW_DECISION_VALUES = new Set(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]);
|
|
63
|
-
const MERGE_METHODS: MergeMethod[] = ["merge", "rebase", "squash"];
|
|
64
61
|
|
|
65
62
|
export class PullRequestLoadError extends Error {
|
|
66
63
|
constructor(message: string) {
|
|
@@ -75,13 +72,6 @@ export type PullRequestRef = {
|
|
|
75
72
|
oid: string;
|
|
76
73
|
};
|
|
77
74
|
|
|
78
|
-
export type MergeMethod = "merge" | "rebase" | "squash";
|
|
79
|
-
|
|
80
|
-
export type PullRequestMerge = {
|
|
81
|
-
allowedMergeMethods: MergeMethod[];
|
|
82
|
-
viewerDefaultMergeMethod: MergeMethod;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
75
|
export type CurrentPullRequest = PullRequest & {
|
|
86
76
|
id: string;
|
|
87
77
|
number: number;
|
|
@@ -92,7 +82,6 @@ export type CurrentPullRequest = PullRequest & {
|
|
|
92
82
|
head: PullRequestRef;
|
|
93
83
|
headFetchSource: string;
|
|
94
84
|
target: PullRequestTarget;
|
|
95
|
-
merge: PullRequestMerge | null;
|
|
96
85
|
};
|
|
97
86
|
|
|
98
87
|
export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
|
|
@@ -105,6 +94,24 @@ export type PullRequestObservation = {
|
|
|
105
94
|
|
|
106
95
|
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
107
96
|
|
|
97
|
+
export type PullRequestCreationPreflight = {
|
|
98
|
+
head: string;
|
|
99
|
+
base: {
|
|
100
|
+
host: string;
|
|
101
|
+
repository: string;
|
|
102
|
+
fetchSource: string;
|
|
103
|
+
ref: string;
|
|
104
|
+
oid: string;
|
|
105
|
+
mergeBase: string;
|
|
106
|
+
};
|
|
107
|
+
ahead: number;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type CreationIdentity = {
|
|
111
|
+
target: PullRequestTarget;
|
|
112
|
+
head: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
108
115
|
type CommandOutput = {
|
|
109
116
|
stdout: string;
|
|
110
117
|
stderr: string;
|
|
@@ -187,6 +194,11 @@ export type BranchUpstreamTarget = {
|
|
|
187
194
|
remoteOid: string;
|
|
188
195
|
};
|
|
189
196
|
|
|
197
|
+
export type BranchUpstreamConfiguration = {
|
|
198
|
+
remote: string[];
|
|
199
|
+
merge: string[];
|
|
200
|
+
};
|
|
201
|
+
|
|
190
202
|
type SearchSelection =
|
|
191
203
|
| { kind: "candidate"; candidate: SearchPullRequest; pullRequest: ListedPullRequest | null }
|
|
192
204
|
| { kind: "none" }
|
|
@@ -202,11 +214,6 @@ type SearchPage = {
|
|
|
202
214
|
endCursor: string | null;
|
|
203
215
|
};
|
|
204
216
|
|
|
205
|
-
type RulesetBranchPolicy = {
|
|
206
|
-
requiresStrictStatusChecks: boolean;
|
|
207
|
-
allowedMergeMethods: MergeMethod[] | null;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
217
|
type CheckState = {
|
|
211
218
|
state: string;
|
|
212
219
|
diagnosableFailure: boolean;
|
|
@@ -332,6 +339,12 @@ function parseCommandOutput(value: unknown, action: string): CommandOutput {
|
|
|
332
339
|
return { stdout, stderr, code, killed };
|
|
333
340
|
}
|
|
334
341
|
|
|
342
|
+
function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
343
|
+
const actual = Object.keys(value).sort();
|
|
344
|
+
const expected = [...keys].sort();
|
|
345
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
346
|
+
}
|
|
347
|
+
|
|
335
348
|
async function invoke(
|
|
336
349
|
pi: Pick<ExtensionAPI, "exec">,
|
|
337
350
|
context: PullRequestLoadContext,
|
|
@@ -865,7 +878,6 @@ function ciStatus(checks: CheckState[]): CiStatus {
|
|
|
865
878
|
function conditions(
|
|
866
879
|
candidate: ListedPullRequest,
|
|
867
880
|
unresolvedThreads: number,
|
|
868
|
-
requiresStrictStatusChecks: boolean,
|
|
869
881
|
): PullRequestConditions {
|
|
870
882
|
if (
|
|
871
883
|
(candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "DIRTY") ||
|
|
@@ -875,19 +887,17 @@ function conditions(
|
|
|
875
887
|
? "pending"
|
|
876
888
|
: "ready";
|
|
877
889
|
const behind = candidate.mergeStateStatus === "BEHIND";
|
|
878
|
-
const policy: PolicyReadiness = candidate.mergeable === "MERGEABLE" &&
|
|
879
|
-
(candidate.mergeStateStatus === "CLEAN" || (behind && !requiresStrictStatusChecks))
|
|
880
|
-
? "ready"
|
|
881
|
-
: "pending";
|
|
882
890
|
return {
|
|
883
891
|
draft: candidate.isDraft,
|
|
884
|
-
baseUpdateRequired: behind
|
|
892
|
+
baseUpdateRequired: behind,
|
|
885
893
|
conflict: candidate.mergeable === "CONFLICTING" || candidate.mergeStateStatus === "DIRTY",
|
|
886
894
|
changesRequested: candidate.reviewDecision === "CHANGES_REQUESTED",
|
|
887
895
|
unresolvedThreads,
|
|
888
896
|
ci: ciStatus(candidate.checkStates),
|
|
889
897
|
review,
|
|
890
|
-
policy
|
|
898
|
+
policy: candidate.mergeable === "MERGEABLE" && candidate.mergeStateStatus === "CLEAN"
|
|
899
|
+
? "ready"
|
|
900
|
+
: "pending",
|
|
891
901
|
};
|
|
892
902
|
}
|
|
893
903
|
|
|
@@ -954,102 +964,6 @@ function parseBaseRefOid(output: string, candidate: ListedPullRequest): string {
|
|
|
954
964
|
return parseBaseRefAuthority(output, candidate.base);
|
|
955
965
|
}
|
|
956
966
|
|
|
957
|
-
function parseLegacyBaseBranchPolicy(output: string, candidate: ListedPullRequest): boolean {
|
|
958
|
-
const value = parseJson(output, "Read base branch policy");
|
|
959
|
-
if (!isRecord(value)) fail("Read base branch policy", "invalid GitHub CLI output");
|
|
960
|
-
if (value.errors !== undefined) {
|
|
961
|
-
if (!Array.isArray(value.errors)) fail("Read base branch policy", "invalid GitHub CLI output");
|
|
962
|
-
if (value.errors.length) fail("Read base branch policy", "GitHub GraphQL returned errors");
|
|
963
|
-
}
|
|
964
|
-
const repository = isRecord(value.data) ? value.data.repository : undefined;
|
|
965
|
-
if (!isRecord(repository) || !isRecord(repository.ref)) {
|
|
966
|
-
fail("Read base branch policy", "invalid GitHub CLI output");
|
|
967
|
-
}
|
|
968
|
-
if (
|
|
969
|
-
normalizeRepository(repositoryName(repository.nameWithOwner, "Read base branch policy", "repository")) !==
|
|
970
|
-
normalizeRepository(candidate.base.repository) ||
|
|
971
|
-
text(repository.ref.name, "Read base branch policy", "ref") !== candidate.base.ref
|
|
972
|
-
) fail("Read base branch policy", "response does not match pull request base");
|
|
973
|
-
const rule = repository.ref.branchProtectionRule;
|
|
974
|
-
if (rule === null) return false;
|
|
975
|
-
if (!isRecord(rule) || typeof rule.requiresStrictStatusChecks !== "boolean") {
|
|
976
|
-
fail("Read base branch policy", "invalid GitHub CLI output");
|
|
977
|
-
}
|
|
978
|
-
return rule.requiresStrictStatusChecks;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function parseRulesetBaseBranchPolicy(output: string): RulesetBranchPolicy {
|
|
982
|
-
const pages = parseJson(output, "Read base branch rulesets");
|
|
983
|
-
if (!Array.isArray(pages) || !pages.length) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
984
|
-
let requiresStrictStatusChecks = false;
|
|
985
|
-
let allowedMergeMethods: Set<MergeMethod> | null = null;
|
|
986
|
-
for (const page of pages) {
|
|
987
|
-
if (!Array.isArray(page)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
988
|
-
for (const rule of page) {
|
|
989
|
-
if (!isRecord(rule)) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
990
|
-
const type = text(rule.type, "Read base branch rulesets", "rule type");
|
|
991
|
-
if (type === "required_status_checks") {
|
|
992
|
-
if (!isRecord(rule.parameters) || typeof rule.parameters.strict_required_status_checks_policy !== "boolean") {
|
|
993
|
-
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
994
|
-
}
|
|
995
|
-
requiresStrictStatusChecks ||= rule.parameters.strict_required_status_checks_policy;
|
|
996
|
-
}
|
|
997
|
-
if (type === "pull_request") {
|
|
998
|
-
if (!isRecord(rule.parameters) || !Array.isArray(rule.parameters.allowed_merge_methods)) {
|
|
999
|
-
fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1000
|
-
}
|
|
1001
|
-
const methods = rule.parameters.allowed_merge_methods;
|
|
1002
|
-
if (
|
|
1003
|
-
methods.some((method) => typeof method !== "string" || !MERGE_METHODS.includes(method as MergeMethod)) ||
|
|
1004
|
-
new Set(methods).size !== methods.length
|
|
1005
|
-
) fail("Read base branch rulesets", "invalid GitHub CLI output");
|
|
1006
|
-
const restriction = new Set<MergeMethod>(methods as MergeMethod[]);
|
|
1007
|
-
allowedMergeMethods = allowedMergeMethods === null
|
|
1008
|
-
? restriction
|
|
1009
|
-
: new Set<MergeMethod>([...allowedMergeMethods].filter((method: MergeMethod) => restriction.has(method)));
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
return {
|
|
1014
|
-
requiresStrictStatusChecks,
|
|
1015
|
-
allowedMergeMethods: allowedMergeMethods === null
|
|
1016
|
-
? null
|
|
1017
|
-
: MERGE_METHODS.filter((method) => allowedMergeMethods.has(method)),
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
function parseMergeMethodSettings(output: string, rulesetMethods: MergeMethod[] | null): PullRequestMerge {
|
|
1022
|
-
const value = parseJson(output, "Read merge methods");
|
|
1023
|
-
if (!isRecord(value)) fail("Read merge methods", "invalid GitHub CLI output");
|
|
1024
|
-
const { mergeCommitAllowed, rebaseMergeAllowed, squashMergeAllowed } = value;
|
|
1025
|
-
if (
|
|
1026
|
-
typeof mergeCommitAllowed !== "boolean" || typeof rebaseMergeAllowed !== "boolean" ||
|
|
1027
|
-
typeof squashMergeAllowed !== "boolean"
|
|
1028
|
-
) fail("Read merge methods", "invalid GitHub CLI output");
|
|
1029
|
-
let allowedMergeMethods: MergeMethod[] = [];
|
|
1030
|
-
if (mergeCommitAllowed) allowedMergeMethods.push("merge");
|
|
1031
|
-
if (rebaseMergeAllowed) allowedMergeMethods.push("rebase");
|
|
1032
|
-
if (squashMergeAllowed) allowedMergeMethods.push("squash");
|
|
1033
|
-
if (!allowedMergeMethods.length) fail("Read merge methods", "repository allows no merge method");
|
|
1034
|
-
const viewerDefaultMergeMethod = value.viewerDefaultMergeMethod === "MERGE"
|
|
1035
|
-
? "merge"
|
|
1036
|
-
: value.viewerDefaultMergeMethod === "REBASE"
|
|
1037
|
-
? "rebase"
|
|
1038
|
-
: value.viewerDefaultMergeMethod === "SQUASH"
|
|
1039
|
-
? "squash"
|
|
1040
|
-
: fail("Read merge methods", "invalid viewerDefaultMergeMethod");
|
|
1041
|
-
if (!allowedMergeMethods.includes(viewerDefaultMergeMethod)) {
|
|
1042
|
-
fail("Read merge methods", "viewerDefaultMergeMethod is not allowed");
|
|
1043
|
-
}
|
|
1044
|
-
if (rulesetMethods !== null) {
|
|
1045
|
-
allowedMergeMethods = allowedMergeMethods.filter((method) => rulesetMethods.includes(method));
|
|
1046
|
-
}
|
|
1047
|
-
if (!allowedMergeMethods.length) {
|
|
1048
|
-
fail("Read merge methods", "repository and applicable rules allow no common merge method");
|
|
1049
|
-
}
|
|
1050
|
-
return { allowedMergeMethods, viewerDefaultMergeMethod };
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
967
|
export async function hasLocalCommit(
|
|
1054
968
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1055
969
|
context: PullRequestLoadContext,
|
|
@@ -1073,6 +987,234 @@ export async function hasLocalCommit(
|
|
|
1073
987
|
return commits[0] !== commits.at(-1);
|
|
1074
988
|
}
|
|
1075
989
|
|
|
990
|
+
function validatedCreationTarget(target: PullRequestTarget): PullRequestTarget {
|
|
991
|
+
if (!isRecord(target)) fail("Read creation target", "invalid target");
|
|
992
|
+
if (target.provenance !== "configured" && target.provenance !== "inferred") {
|
|
993
|
+
fail("Read creation target", "invalid provenance");
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
provenance: target.provenance,
|
|
997
|
+
branch: text(target.branch, "Read creation target", "branch"),
|
|
998
|
+
remote: text(target.remote, "Read creation target", "remote"),
|
|
999
|
+
ref: text(target.ref, "Read creation target", "ref"),
|
|
1000
|
+
repository: repositoryName(target.repository, "Read creation target", "repository"),
|
|
1001
|
+
host: text(target.host, "Read creation target", "host").toLowerCase(),
|
|
1002
|
+
fetchSource: text(target.fetchSource, "Read creation target", "fetch source"),
|
|
1003
|
+
remoteOid: target.remoteOid === null ? null : oid(target.remoteOid, "Read creation target", "remote OID"),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function sameCreationTarget(left: PullRequestTarget, right: PullRequestTarget): boolean {
|
|
1008
|
+
return left.provenance === right.provenance && left.branch === right.branch &&
|
|
1009
|
+
left.remote === right.remote && left.ref === right.ref &&
|
|
1010
|
+
normalizeRepository(left.repository) === normalizeRepository(right.repository) &&
|
|
1011
|
+
left.host === right.host && left.fetchSource === right.fetchSource && left.remoteOid === right.remoteOid;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function validateCreationRef(
|
|
1015
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1016
|
+
context: PullRequestLoadContext,
|
|
1017
|
+
ref: string,
|
|
1018
|
+
): Promise<string> {
|
|
1019
|
+
const requested = text(ref, "Validate creation base", "ref");
|
|
1020
|
+
const checked = singleLine(
|
|
1021
|
+
(await execute(pi, context, "Validate creation base", "git", ["check-ref-format", "--branch", requested])).stdout,
|
|
1022
|
+
"Validate creation base",
|
|
1023
|
+
"ref",
|
|
1024
|
+
);
|
|
1025
|
+
if (checked !== requested) fail("Validate creation base", "ref changed");
|
|
1026
|
+
return requested;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function captureCreationIdentity(
|
|
1030
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1031
|
+
context: PullRequestLoadContext,
|
|
1032
|
+
target: PullRequestTarget,
|
|
1033
|
+
): Promise<CreationIdentity> {
|
|
1034
|
+
const validatedTarget = validatedCreationTarget(target);
|
|
1035
|
+
const branch = singleLine(
|
|
1036
|
+
(await execute(pi, context, "Read creation branch", "git", ["branch", "--show-current"])).stdout,
|
|
1037
|
+
"Read creation branch",
|
|
1038
|
+
"branch",
|
|
1039
|
+
);
|
|
1040
|
+
if (branch !== validatedTarget.branch) fail("Read creation branch", "branch changed");
|
|
1041
|
+
await validateCreationRef(pi, context, branch);
|
|
1042
|
+
const head = oid(singleLine(
|
|
1043
|
+
(await execute(pi, context, "Read creation HEAD", "git", ["rev-parse", "--verify", "HEAD^{commit}"])).stdout,
|
|
1044
|
+
"Read creation HEAD",
|
|
1045
|
+
"OID",
|
|
1046
|
+
), "Read creation HEAD", "OID");
|
|
1047
|
+
return { target: validatedTarget, head };
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function readConfiguredCreationBaseRef(
|
|
1051
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1052
|
+
context: PullRequestLoadContext,
|
|
1053
|
+
branch: string,
|
|
1054
|
+
): Promise<string | null> {
|
|
1055
|
+
const result = await invoke(pi, context, "Read creation base configuration", "git", [
|
|
1056
|
+
"config", "--get-all", `branch.${branch}.gh-merge-base`,
|
|
1057
|
+
]);
|
|
1058
|
+
if (result.killed) commandFailure("Read creation base configuration", result);
|
|
1059
|
+
if (result.code === 1 && result.stdout === "" && result.stderr === "") return null;
|
|
1060
|
+
if (result.code !== 0) commandFailure("Read creation base configuration", result);
|
|
1061
|
+
if (result.stderr !== "") fail("Read creation base configuration", "unexpected diagnostic");
|
|
1062
|
+
const values = lines(result.stdout, "Read creation base configuration", "base ref");
|
|
1063
|
+
if (values.length !== 1) fail("Read creation base configuration", "multiple base refs");
|
|
1064
|
+
return await validateCreationRef(pi, context, values[0]!);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function parseDefaultCreationBaseRef(output: string): string {
|
|
1068
|
+
const value = parseJson(output, "Read creation default branch");
|
|
1069
|
+
if (!isRecord(value) || !hasExactKeys(value, ["defaultBranchRef"]) || !isRecord(value.defaultBranchRef) ||
|
|
1070
|
+
!hasExactKeys(value.defaultBranchRef, ["name"])) {
|
|
1071
|
+
fail("Read creation default branch", "invalid GitHub CLI output");
|
|
1072
|
+
}
|
|
1073
|
+
return text(value.defaultBranchRef.name, "Read creation default branch", "default branch ref");
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function readDefaultCreationBaseRef(
|
|
1077
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1078
|
+
context: PullRequestLoadContext,
|
|
1079
|
+
origin: { repository: PushRepository },
|
|
1080
|
+
): Promise<string> {
|
|
1081
|
+
const result = await execute(pi, context, "Read creation default branch", "gh", [
|
|
1082
|
+
"repo", "view", `${origin.repository.host}/${origin.repository.nameWithOwner}`, "--json", "defaultBranchRef",
|
|
1083
|
+
]);
|
|
1084
|
+
if (result.stderr !== "") fail("Read creation default branch", "unexpected diagnostic");
|
|
1085
|
+
return await validateCreationRef(pi, context, parseDefaultCreationBaseRef(result.stdout));
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function parseCreationRepositoryLineage(output: string, expected: PushRepository): string {
|
|
1089
|
+
const value = parseJson(output, "Read creation repository");
|
|
1090
|
+
if (!isRecord(value)) fail("Read creation repository", "invalid GitHub CLI output");
|
|
1091
|
+
const fullName = repositoryName(value.full_name, "Read creation repository", "full_name");
|
|
1092
|
+
const url = parseHttpUrl(value.html_url, "Read creation repository", "html_url");
|
|
1093
|
+
if (
|
|
1094
|
+
normalizeRepository(fullName) !== expected.normalizedName || url.protocol !== "https:" || url.port ||
|
|
1095
|
+
url.hostname.toLowerCase() !== expected.host || url.pathname.toLowerCase() !== `/${expected.normalizedName}`
|
|
1096
|
+
) fail("Read creation repository", "response does not match repository");
|
|
1097
|
+
const source = value.source;
|
|
1098
|
+
if (source !== undefined && source !== null && !isRecord(source)) {
|
|
1099
|
+
fail("Read creation repository", "invalid source");
|
|
1100
|
+
}
|
|
1101
|
+
const sourceName = source === undefined || source === null
|
|
1102
|
+
? fullName
|
|
1103
|
+
: repositoryName(source.full_name, "Read creation repository", "source.full_name");
|
|
1104
|
+
return normalizeRepository(sourceName);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async function assertCreationRepositoryRelation(
|
|
1108
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1109
|
+
context: PullRequestLoadContext,
|
|
1110
|
+
target: PullRequestTarget,
|
|
1111
|
+
origin: { repository: PushRepository },
|
|
1112
|
+
baseRef: string,
|
|
1113
|
+
): Promise<void> {
|
|
1114
|
+
if (target.host !== origin.repository.host) {
|
|
1115
|
+
fail("Read creation repository", "base and head hosts do not match");
|
|
1116
|
+
}
|
|
1117
|
+
if (normalizeRepository(target.repository) === origin.repository.normalizedName) {
|
|
1118
|
+
if (target.ref === baseRef) fail("Read creation repository", "head and base refs match");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const read = async (repository: PushRepository): Promise<string> => {
|
|
1122
|
+
const [owner, name] = repository.nameWithOwner.split("/");
|
|
1123
|
+
const result = await execute(pi, context, "Read creation repository", "gh", [
|
|
1124
|
+
"api", "--hostname", origin.repository.host, `repos/${owner}/${name}`,
|
|
1125
|
+
]);
|
|
1126
|
+
if (result.stderr !== "") fail("Read creation repository", "unexpected diagnostic");
|
|
1127
|
+
return parseCreationRepositoryLineage(result.stdout, repository);
|
|
1128
|
+
};
|
|
1129
|
+
const originSource = await read(origin.repository);
|
|
1130
|
+
const targetSource = await read({
|
|
1131
|
+
nameWithOwner: target.repository,
|
|
1132
|
+
normalizedName: normalizeRepository(target.repository),
|
|
1133
|
+
host: target.host,
|
|
1134
|
+
});
|
|
1135
|
+
if (originSource !== targetSource) fail("Read creation repository", "base and head are unrelated");
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function parseCreationAhead(output: string): number {
|
|
1139
|
+
const value = singleLine(output, "Count creation commits", "ahead count");
|
|
1140
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) fail("Count creation commits", "invalid ahead count");
|
|
1141
|
+
const ahead = Number(value);
|
|
1142
|
+
if (!Number.isSafeInteger(ahead) || ahead < 0) fail("Count creation commits", "invalid ahead count");
|
|
1143
|
+
return ahead;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async function preflightCreation(
|
|
1147
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1148
|
+
context: PullRequestLoadContext,
|
|
1149
|
+
target: PullRequestTarget,
|
|
1150
|
+
explicitBaseRef: string | undefined,
|
|
1151
|
+
identity: CreationIdentity,
|
|
1152
|
+
): Promise<PullRequestCreationPreflight> {
|
|
1153
|
+
const validatedTarget = validatedCreationTarget(target);
|
|
1154
|
+
if (!sameCreationTarget(identity.target, validatedTarget)) {
|
|
1155
|
+
fail("Read creation target", "target changed");
|
|
1156
|
+
}
|
|
1157
|
+
const origin = await readRemoteAuthority(pi, context, "origin", true);
|
|
1158
|
+
if (!origin) fail("Read creation repository", "origin is unavailable");
|
|
1159
|
+
const configuredBaseRef = explicitBaseRef === undefined
|
|
1160
|
+
? await readConfiguredCreationBaseRef(pi, context, identity.target.branch)
|
|
1161
|
+
: await validateCreationRef(pi, context, explicitBaseRef);
|
|
1162
|
+
const baseRef = configuredBaseRef ?? await readDefaultCreationBaseRef(pi, context, origin);
|
|
1163
|
+
await assertCreationRepositoryRelation(pi, context, identity.target, origin, baseRef);
|
|
1164
|
+
const trackingRef = `refs/remotes/origin/${baseRef}`;
|
|
1165
|
+
await execute(pi, context, "Fetch creation base", "git", [
|
|
1166
|
+
"fetch", "--no-write-fetch-head", "--no-tags", "--no-recurse-submodules", "--",
|
|
1167
|
+
origin.fetchSource, `+refs/heads/${baseRef}:${trackingRef}`,
|
|
1168
|
+
]);
|
|
1169
|
+
const baseOid = oid(singleLine(
|
|
1170
|
+
(await execute(pi, context, "Read creation base", "git", ["rev-parse", "--verify", `${trackingRef}^{commit}`])).stdout,
|
|
1171
|
+
"Read creation base",
|
|
1172
|
+
"OID",
|
|
1173
|
+
), "Read creation base", "OID");
|
|
1174
|
+
const mergeBase = oid(singleLine(
|
|
1175
|
+
(await execute(pi, context, "Find creation merge base", "git", ["merge-base", identity.head, baseOid])).stdout,
|
|
1176
|
+
"Find creation merge base",
|
|
1177
|
+
"OID",
|
|
1178
|
+
), "Find creation merge base", "OID");
|
|
1179
|
+
const ahead = parseCreationAhead((await execute(pi, context, "Count creation commits", "git", [
|
|
1180
|
+
"rev-list", "--count", `${mergeBase}..${identity.head}`,
|
|
1181
|
+
])).stdout);
|
|
1182
|
+
return {
|
|
1183
|
+
head: identity.head,
|
|
1184
|
+
base: {
|
|
1185
|
+
host: origin.repository.host,
|
|
1186
|
+
repository: origin.repository.nameWithOwner,
|
|
1187
|
+
fetchSource: origin.fetchSource,
|
|
1188
|
+
ref: baseRef,
|
|
1189
|
+
oid: baseOid,
|
|
1190
|
+
mergeBase,
|
|
1191
|
+
},
|
|
1192
|
+
ahead,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export async function preflightPullRequestCreation(
|
|
1197
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1198
|
+
context: PullRequestLoadContext,
|
|
1199
|
+
target: PullRequestTarget,
|
|
1200
|
+
explicitBaseRef?: string,
|
|
1201
|
+
): Promise<PullRequestCreationPreflight> {
|
|
1202
|
+
const identity = await captureCreationIdentity(pi, context, target);
|
|
1203
|
+
return await preflightCreation(pi, context, target, explicitBaseRef, identity);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function creationDiscovery(
|
|
1207
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1208
|
+
context: PullRequestLoadContext,
|
|
1209
|
+
target: PullRequestTarget,
|
|
1210
|
+
explicitBaseRef: string | undefined,
|
|
1211
|
+
identity?: CreationIdentity,
|
|
1212
|
+
): Promise<CurrentPullRequestDiscovery> {
|
|
1213
|
+
const captured = identity ?? await captureCreationIdentity(pi, context, target);
|
|
1214
|
+
const preflight = await preflightCreation(pi, context, target, explicitBaseRef, captured);
|
|
1215
|
+
return { kind: "none", creationTarget: target, branch: { ahead: preflight.ahead } };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1076
1218
|
async function readRemoteAuthority(
|
|
1077
1219
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1078
1220
|
context: PullRequestLoadContext,
|
|
@@ -1167,6 +1309,22 @@ async function readConfigValues(
|
|
|
1167
1309
|
}
|
|
1168
1310
|
}
|
|
1169
1311
|
|
|
1312
|
+
export async function readBranchUpstreamConfiguration(
|
|
1313
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1314
|
+
context: PullRequestLoadContext,
|
|
1315
|
+
branch: string,
|
|
1316
|
+
): Promise<BranchUpstreamConfiguration> {
|
|
1317
|
+
const checkedBranch = text(branch, "Read branch upstream", "branch");
|
|
1318
|
+
const [remote, merge] = await Promise.all([
|
|
1319
|
+
readConfigValues(pi, context, `branch.${checkedBranch}.remote`),
|
|
1320
|
+
readConfigValues(pi, context, `branch.${checkedBranch}.merge`),
|
|
1321
|
+
]);
|
|
1322
|
+
if (remote === null || merge === null) {
|
|
1323
|
+
throw new Error("Read branch upstream failed: invalid Git configuration");
|
|
1324
|
+
}
|
|
1325
|
+
return { remote, merge };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1170
1328
|
async function readBooleanConfigValues(
|
|
1171
1329
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1172
1330
|
context: PullRequestLoadContext,
|
|
@@ -1440,66 +1598,6 @@ export async function readPullRequestBaseRefOid(
|
|
|
1440
1598
|
return parseBaseRefAuthority(result.stdout, { repository, ref });
|
|
1441
1599
|
}
|
|
1442
1600
|
|
|
1443
|
-
async function readLegacyBaseBranchPolicy(
|
|
1444
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1445
|
-
context: PullRequestLoadContext,
|
|
1446
|
-
candidate: ListedPullRequest,
|
|
1447
|
-
): Promise<boolean> {
|
|
1448
|
-
const [owner, name] = candidate.base.repository.split("/");
|
|
1449
|
-
const result = await execute(pi, context, "Read base branch policy", "gh", [
|
|
1450
|
-
"api",
|
|
1451
|
-
"graphql",
|
|
1452
|
-
"--hostname",
|
|
1453
|
-
candidate.url.hostname,
|
|
1454
|
-
"-f",
|
|
1455
|
-
`query=${BASE_BRANCH_POLICY_QUERY}`,
|
|
1456
|
-
"-F",
|
|
1457
|
-
`owner=${owner}`,
|
|
1458
|
-
"-F",
|
|
1459
|
-
`name=${name}`,
|
|
1460
|
-
"-F",
|
|
1461
|
-
`qualifiedName=refs/heads/${candidate.base.ref}`,
|
|
1462
|
-
]);
|
|
1463
|
-
return parseLegacyBaseBranchPolicy(result.stdout, candidate);
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
async function readRulesetBaseBranchPolicy(
|
|
1467
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1468
|
-
context: PullRequestLoadContext,
|
|
1469
|
-
candidate: ListedPullRequest,
|
|
1470
|
-
): Promise<RulesetBranchPolicy> {
|
|
1471
|
-
const [owner, name] = candidate.base.repository.split("/");
|
|
1472
|
-
const result = await execute(pi, context, "Read base branch rulesets", "gh", [
|
|
1473
|
-
"api",
|
|
1474
|
-
"--hostname",
|
|
1475
|
-
candidate.url.hostname,
|
|
1476
|
-
"--paginate",
|
|
1477
|
-
"--slurp",
|
|
1478
|
-
"-H",
|
|
1479
|
-
"Accept: application/vnd.github+json",
|
|
1480
|
-
"-H",
|
|
1481
|
-
"X-GitHub-Api-Version: 2022-11-28",
|
|
1482
|
-
`repos/${owner}/${name}/rules/branches/${encodeURIComponent(candidate.base.ref)}`,
|
|
1483
|
-
]);
|
|
1484
|
-
return parseRulesetBaseBranchPolicy(result.stdout);
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
async function readMergeMethods(
|
|
1488
|
-
pi: Pick<ExtensionAPI, "exec">,
|
|
1489
|
-
context: PullRequestLoadContext,
|
|
1490
|
-
candidate: ListedPullRequest,
|
|
1491
|
-
rulesetMethods: MergeMethod[] | null,
|
|
1492
|
-
): Promise<PullRequestMerge> {
|
|
1493
|
-
const result = await execute(pi, context, "Read merge methods", "gh", [
|
|
1494
|
-
"repo",
|
|
1495
|
-
"view",
|
|
1496
|
-
`${candidate.url.hostname}/${candidate.base.repository}`,
|
|
1497
|
-
"--json",
|
|
1498
|
-
"mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed,viewerDefaultMergeMethod",
|
|
1499
|
-
]);
|
|
1500
|
-
return parseMergeMethodSettings(result.stdout, rulesetMethods);
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
1601
|
async function loadPullRequestDetails(
|
|
1504
1602
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1505
1603
|
context: PullRequestLoadContext,
|
|
@@ -1518,17 +1616,7 @@ async function loadPullRequestDetails(
|
|
|
1518
1616
|
const liveBaseOid = candidate.lifecycle === "open"
|
|
1519
1617
|
? await readBaseRefOid(pi, context, candidate)
|
|
1520
1618
|
: null;
|
|
1521
|
-
const
|
|
1522
|
-
? await readRulesetBaseBranchPolicy(pi, context, candidate)
|
|
1523
|
-
: null;
|
|
1524
|
-
const legacyStrict = candidate.lifecycle === "open" && candidate.mergeStateStatus === "BEHIND"
|
|
1525
|
-
? await readLegacyBaseBranchPolicy(pi, context, candidate)
|
|
1526
|
-
: false;
|
|
1527
|
-
const requiresStrictStatusChecks = legacyStrict || (rulesetPolicy?.requiresStrictStatusChecks ?? false);
|
|
1528
|
-
const pullRequestConditions = conditions(candidate, unresolvedThreads, requiresStrictStatusChecks);
|
|
1529
|
-
const merge = candidate.lifecycle === "open"
|
|
1530
|
-
? await readMergeMethods(pi, context, candidate, rulesetPolicy?.allowedMergeMethods ?? null)
|
|
1531
|
-
: null;
|
|
1619
|
+
const pullRequestConditions = conditions(candidate, unresolvedThreads);
|
|
1532
1620
|
const inspected = inspectedLocal ?? await inspectLocalMergeSafety({
|
|
1533
1621
|
exec: (command, args, options) => pi.exec(command, args, {
|
|
1534
1622
|
...options,
|
|
@@ -1553,7 +1641,6 @@ async function loadPullRequestDetails(
|
|
|
1553
1641
|
head: candidate.head,
|
|
1554
1642
|
headFetchSource: pushTarget.fetchSource,
|
|
1555
1643
|
target: publicTarget(pushTarget),
|
|
1556
|
-
merge,
|
|
1557
1644
|
};
|
|
1558
1645
|
}
|
|
1559
1646
|
|
|
@@ -1694,6 +1781,18 @@ export async function loadCurrentPullRequest(
|
|
|
1694
1781
|
context: PullRequestLoadContext,
|
|
1695
1782
|
inspectedLocal?: LocalMergeSafety,
|
|
1696
1783
|
observed?: unknown,
|
|
1784
|
+
explicitCreationBase?: string,
|
|
1785
|
+
): Promise<CurrentPullRequestDiscovery> {
|
|
1786
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase);
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
async function loadCurrentPullRequestInternal(
|
|
1790
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1791
|
+
context: PullRequestLoadContext,
|
|
1792
|
+
inspectedLocal: LocalMergeSafety | undefined,
|
|
1793
|
+
observed: unknown,
|
|
1794
|
+
explicitCreationBase: string | undefined,
|
|
1795
|
+
creationIdentity?: CreationIdentity,
|
|
1697
1796
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1698
1797
|
const read = await readPushTarget(pi, context);
|
|
1699
1798
|
if (read.kind === "inactive") return { kind: "inactive" };
|
|
@@ -1714,10 +1813,18 @@ export async function loadCurrentPullRequest(
|
|
|
1714
1813
|
};
|
|
1715
1814
|
}
|
|
1716
1815
|
if (inferred.kind === "none") {
|
|
1816
|
+
const target = publicTarget(inferred.target);
|
|
1817
|
+
if (creationIdentity === undefined) {
|
|
1818
|
+
const captured = await captureCreationIdentity(pi, context, target);
|
|
1819
|
+
return await loadCurrentPullRequestInternal(pi, context, inspectedLocal, observed, explicitCreationBase, captured);
|
|
1820
|
+
}
|
|
1821
|
+
if (!sameCreationTarget(creationIdentity.target, validatedCreationTarget(target))) {
|
|
1822
|
+
fail("Read creation target", "target changed");
|
|
1823
|
+
}
|
|
1717
1824
|
if (!canLinkTarget(await readLinkConfiguration(pi, context, inferred.target), inferred.target)) {
|
|
1718
1825
|
return { kind: "blocked", issue: { kind: "link-configuration", remote: inferred.target.remote } };
|
|
1719
1826
|
}
|
|
1720
|
-
return
|
|
1827
|
+
return await creationDiscovery(pi, context, target, explicitCreationBase, creationIdentity);
|
|
1721
1828
|
}
|
|
1722
1829
|
pushTarget = inferred.target;
|
|
1723
1830
|
} else {
|
|
@@ -1814,7 +1921,10 @@ export async function loadCurrentPullRequest(
|
|
|
1814
1921
|
}
|
|
1815
1922
|
throw error;
|
|
1816
1923
|
}
|
|
1817
|
-
if (candidate === null)
|
|
1924
|
+
if (candidate === null) {
|
|
1925
|
+
if (creationIdentity !== undefined) fail("Read creation target", "target changed");
|
|
1926
|
+
return await creationDiscovery(pi, context, publicTarget(pushTarget), explicitCreationBase);
|
|
1927
|
+
}
|
|
1818
1928
|
}
|
|
1819
1929
|
|
|
1820
1930
|
return {
|
|
@@ -1942,6 +2052,42 @@ function sameConfigValues(left: readonly string[], right: readonly string[]): bo
|
|
|
1942
2052
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1943
2053
|
}
|
|
1944
2054
|
|
|
2055
|
+
export async function restoreBranchUpstreamConfiguration(
|
|
2056
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
2057
|
+
context: PullRequestLoadContext,
|
|
2058
|
+
target: Pick<BranchUpstreamTarget, "branch" | "remote" | "ref">,
|
|
2059
|
+
original: BranchUpstreamConfiguration,
|
|
2060
|
+
): Promise<void> {
|
|
2061
|
+
let incomplete = false;
|
|
2062
|
+
for (const [key, expected, values] of [
|
|
2063
|
+
[`branch.${target.branch}.remote`, target.remote, original.remote],
|
|
2064
|
+
[`branch.${target.branch}.merge`, `refs/heads/${target.ref}`, original.merge],
|
|
2065
|
+
] as const) {
|
|
2066
|
+
try {
|
|
2067
|
+
const current = await readConfigValues(pi, context, key);
|
|
2068
|
+
if (current === null) incomplete = true;
|
|
2069
|
+
else if (sameConfigValues(current, values)) continue;
|
|
2070
|
+
else if (current.length === 1 && current[0] === expected) {
|
|
2071
|
+
await restoreConfigValue(pi, context, key, expected, values);
|
|
2072
|
+
} else incomplete = true;
|
|
2073
|
+
} catch {
|
|
2074
|
+
incomplete = true;
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
for (const [key, values] of [
|
|
2078
|
+
[`branch.${target.branch}.remote`, original.remote],
|
|
2079
|
+
[`branch.${target.branch}.merge`, original.merge],
|
|
2080
|
+
] as const) {
|
|
2081
|
+
try {
|
|
2082
|
+
const current = await readConfigValues(pi, context, key);
|
|
2083
|
+
if (current === null || !sameConfigValues(current, values)) incomplete = true;
|
|
2084
|
+
} catch {
|
|
2085
|
+
incomplete = true;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
if (incomplete) throw new Error("Restore branch upstream failed and rollback was incomplete");
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1945
2091
|
async function restoreLinkState(
|
|
1946
2092
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1947
2093
|
context: PullRequestLoadContext,
|