@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.
@@ -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 && requiresStrictStatusChecks,
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 rulesetPolicy = candidate.lifecycle === "open"
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 { kind: "none", creationTarget: publicTarget(inferred.target) };
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) return { kind: "none", creationTarget: publicTarget(pushTarget) };
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,