@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,831 @@
|
|
|
1
|
+
// GitHub adapter: `gh api graphql` for PR state, checks and review threads,
|
|
2
|
+
// `gh api` REST for required checks, job logs and reruns (design §2.0).
|
|
3
|
+
import {
|
|
4
|
+
apiJson,
|
|
5
|
+
apiText,
|
|
6
|
+
apiWrite,
|
|
7
|
+
cliFailure,
|
|
8
|
+
FORGE_TIMEOUTS,
|
|
9
|
+
isRateLimited,
|
|
10
|
+
runLimited,
|
|
11
|
+
type ForgeRunner,
|
|
12
|
+
} from "./exec";
|
|
13
|
+
import { logTail, redactText, shortBody } from "./redact";
|
|
14
|
+
import { rollupForHead } from "./pr-status";
|
|
15
|
+
import {
|
|
16
|
+
failure,
|
|
17
|
+
success,
|
|
18
|
+
type Forge,
|
|
19
|
+
type ForgeCheck,
|
|
20
|
+
type ForgePrStatus,
|
|
21
|
+
type ForgeResult,
|
|
22
|
+
type ForgeThread,
|
|
23
|
+
type PrMeta,
|
|
24
|
+
type PrRef,
|
|
25
|
+
type PrState,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
/** Page cap for review threads and check contexts (100 per page). */
|
|
29
|
+
export const GITHUB_MAX_PAGES = 10;
|
|
30
|
+
|
|
31
|
+
const PR_FIELDS = `number url state isDraft isInMergeQueue mergeable mergeStateStatus baseRefName baseRefOid baseRef { target { oid } } headRefName headRefOid reviewDecision`;
|
|
32
|
+
|
|
33
|
+
const THREADS = `reviewThreads(first: 100, after: $threads) { pageInfo { hasNextPage endCursor } nodes { id isResolved isOutdated path line comments(first: 1) { nodes { author { login __typename } body url } } } }`;
|
|
34
|
+
|
|
35
|
+
// isRequired: only checks branch protection requires gate the merge.
|
|
36
|
+
const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPage endCursor } nodes { __typename ... on CheckRun { name status conclusion detailsUrl databaseId isRequired(pullRequestNumber: $number) checkSuite { workflowRun { databaseId workflow { name } } } } ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $number) } } }`;
|
|
37
|
+
|
|
38
|
+
export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
|
|
39
|
+
|
|
40
|
+
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!, $after: String) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, after: $after, orderBy: { field: CREATED_AT, direction: DESC }) { pageInfo { hasNextPage endCursor } nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
41
|
+
|
|
42
|
+
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 } } } } } } } }`;
|
|
43
|
+
|
|
44
|
+
const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
45
|
+
const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
46
|
+
const REPLY_MUTATION = `mutation($thread: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $thread, body: $body }) { comment { url } } }`;
|
|
47
|
+
const RESOLVE_MUTATION = `mutation($thread: ID!) { resolveReviewThread(input: { threadId: $thread }) { thread { isResolved } } }`;
|
|
48
|
+
|
|
49
|
+
const THREAD_QUERY = `query($thread: ID!) { node(id: $thread) { ... on PullRequestReviewThread { isResolved pullRequest { number } } } }`;
|
|
50
|
+
|
|
51
|
+
type RestPr = {
|
|
52
|
+
number?: number;
|
|
53
|
+
html_url?: string;
|
|
54
|
+
node_id?: string;
|
|
55
|
+
state?: string;
|
|
56
|
+
merged?: boolean;
|
|
57
|
+
draft?: boolean;
|
|
58
|
+
title?: string;
|
|
59
|
+
base?: { ref?: string; repo?: { full_name?: string } | null };
|
|
60
|
+
head?: { ref?: string; repo?: { full_name?: string } | null };
|
|
61
|
+
user?: { login?: string } | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type Page<T> = { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] };
|
|
65
|
+
|
|
66
|
+
type GqlThread = {
|
|
67
|
+
id: string;
|
|
68
|
+
isResolved: boolean;
|
|
69
|
+
isOutdated: boolean;
|
|
70
|
+
path: string | null;
|
|
71
|
+
line: number | null;
|
|
72
|
+
comments: {
|
|
73
|
+
nodes: Array<{
|
|
74
|
+
author: { login: string; __typename: string } | null;
|
|
75
|
+
body: string;
|
|
76
|
+
url: string;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type GqlContext =
|
|
82
|
+
| {
|
|
83
|
+
__typename: "CheckRun";
|
|
84
|
+
name: string;
|
|
85
|
+
status: string;
|
|
86
|
+
conclusion: string | null;
|
|
87
|
+
detailsUrl: string | null;
|
|
88
|
+
databaseId: number | null;
|
|
89
|
+
isRequired?: boolean | null;
|
|
90
|
+
checkSuite: {
|
|
91
|
+
workflowRun: { databaseId: number; workflow: { name: string } | null } | null;
|
|
92
|
+
} | null;
|
|
93
|
+
}
|
|
94
|
+
| {
|
|
95
|
+
__typename: "StatusContext";
|
|
96
|
+
context: string;
|
|
97
|
+
state: string;
|
|
98
|
+
targetUrl: string | null;
|
|
99
|
+
isRequired?: boolean | null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
type GqlPr = {
|
|
103
|
+
number: number;
|
|
104
|
+
url: string;
|
|
105
|
+
state: "OPEN" | "CLOSED" | "MERGED";
|
|
106
|
+
isDraft: boolean;
|
|
107
|
+
isInMergeQueue?: boolean;
|
|
108
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
109
|
+
mergeStateStatus: string;
|
|
110
|
+
baseRefName: string;
|
|
111
|
+
/** baseRefOid is the base at the PR's last sync; baseRef.target is the live tip. */
|
|
112
|
+
baseRefOid: string | null;
|
|
113
|
+
baseRef?: { target: { oid: string } | null } | null;
|
|
114
|
+
headRefName: string;
|
|
115
|
+
headRefOid: string;
|
|
116
|
+
reviewDecision: string | null;
|
|
117
|
+
reviewThreads: Page<GqlThread>;
|
|
118
|
+
commits: {
|
|
119
|
+
nodes: Array<{
|
|
120
|
+
commit: {
|
|
121
|
+
oid: string;
|
|
122
|
+
statusCheckRollup: { state: string; contexts: Page<GqlContext> } | null;
|
|
123
|
+
};
|
|
124
|
+
}>;
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type BranchChecksData = {
|
|
129
|
+
repository: {
|
|
130
|
+
object: { oid?: string; statusCheckRollup: { contexts: Page<GqlContext> } | null } | null;
|
|
131
|
+
} | null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type GqlResponse<T> = { data?: T; errors?: Array<{ message?: string; type?: string }> };
|
|
135
|
+
|
|
136
|
+
const prState = (value: string): PrState =>
|
|
137
|
+
value === "MERGED" ? "merged" : value === "CLOSED" ? "closed" : "open";
|
|
138
|
+
|
|
139
|
+
// GitHub Actions conclusions that fail a PR. NEUTRAL/SKIPPED/SUCCESS pass;
|
|
140
|
+
// STALE is superseded by a newer run and is ignored.
|
|
141
|
+
const FAILING = new Set([
|
|
142
|
+
"FAILURE",
|
|
143
|
+
"TIMED_OUT",
|
|
144
|
+
"CANCELLED",
|
|
145
|
+
"ACTION_REQUIRED",
|
|
146
|
+
"STARTUP_FAILURE",
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
const safeUrl = (value: string | null): string | null => (value ? redactText(value) : null);
|
|
150
|
+
|
|
151
|
+
export function checkFromContext(node: GqlContext): ForgeCheck | null {
|
|
152
|
+
const required = node.isRequired ?? null;
|
|
153
|
+
if (node.__typename === "StatusContext") {
|
|
154
|
+
const state = node.state.toUpperCase();
|
|
155
|
+
return {
|
|
156
|
+
name: node.context,
|
|
157
|
+
context: node.context,
|
|
158
|
+
state:
|
|
159
|
+
state === "SUCCESS"
|
|
160
|
+
? "passing"
|
|
161
|
+
: state === "FAILURE" || state === "ERROR"
|
|
162
|
+
? "failing"
|
|
163
|
+
: "pending",
|
|
164
|
+
conclusion: state.toLowerCase(),
|
|
165
|
+
url: safeUrl(node.targetUrl),
|
|
166
|
+
runId: null,
|
|
167
|
+
jobId: null,
|
|
168
|
+
scope: null,
|
|
169
|
+
required,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (node.conclusion === "STALE") return null;
|
|
173
|
+
const run = node.checkSuite?.workflowRun ?? null;
|
|
174
|
+
const workflow = run?.workflow?.name;
|
|
175
|
+
const completed = node.status === "COMPLETED";
|
|
176
|
+
const conclusion = node.conclusion?.toUpperCase() ?? null;
|
|
177
|
+
return {
|
|
178
|
+
name: workflow ? `${workflow} / ${node.name}` : node.name,
|
|
179
|
+
context: node.name,
|
|
180
|
+
state: !completed
|
|
181
|
+
? "pending"
|
|
182
|
+
: conclusion && FAILING.has(conclusion)
|
|
183
|
+
? "failing"
|
|
184
|
+
: conclusion === "SKIPPED" || conclusion === "NEUTRAL"
|
|
185
|
+
? "skipped"
|
|
186
|
+
: "passing",
|
|
187
|
+
conclusion: completed ? (conclusion?.toLowerCase() ?? null) : node.status.toLowerCase(),
|
|
188
|
+
url: safeUrl(node.detailsUrl),
|
|
189
|
+
runId: run?.databaseId ?? null,
|
|
190
|
+
// Only Actions check runs have a job log behind their database id.
|
|
191
|
+
jobId: run ? (node.databaseId ?? null) : null,
|
|
192
|
+
scope: null,
|
|
193
|
+
required,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One check per name: a re-run check replaces its earlier attempt. */
|
|
198
|
+
export function latestPerName(checks: readonly ForgeCheck[]): ForgeCheck[] {
|
|
199
|
+
const byName = new Map<string, ForgeCheck>();
|
|
200
|
+
for (const check of checks) {
|
|
201
|
+
const seen = byName.get(check.name);
|
|
202
|
+
if (!seen || (check.jobId ?? 0) >= (seen.jobId ?? 0)) byName.set(check.name, check);
|
|
203
|
+
}
|
|
204
|
+
return [...byName.values()];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function threadFrom(node: GqlThread): ForgeThread {
|
|
208
|
+
const first = node.comments.nodes[0];
|
|
209
|
+
return {
|
|
210
|
+
id: node.id,
|
|
211
|
+
path: node.path,
|
|
212
|
+
line: node.line,
|
|
213
|
+
author: first?.author?.login ?? null,
|
|
214
|
+
isBot: first?.author?.__typename === "Bot",
|
|
215
|
+
outdated: node.isOutdated,
|
|
216
|
+
url: safeUrl(first?.url ?? null),
|
|
217
|
+
body: shortBody(first?.body ?? ""),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const encodeRef = (ref: string): string => ref.split("/").map(encodeURIComponent).join("/");
|
|
222
|
+
|
|
223
|
+
export function createGitHubForge(options: {
|
|
224
|
+
apiHost: string;
|
|
225
|
+
repo: string;
|
|
226
|
+
runner: ForgeRunner;
|
|
227
|
+
maxPages?: number;
|
|
228
|
+
}): Forge {
|
|
229
|
+
const { apiHost, repo, runner } = options;
|
|
230
|
+
const maxPages = options.maxPages ?? GITHUB_MAX_PAGES;
|
|
231
|
+
const [owner, name] = repo.split("/");
|
|
232
|
+
|
|
233
|
+
const rest = <T>(endpoint: string, what: string): ForgeResult<T> =>
|
|
234
|
+
apiJson<T>(runner, "gh", apiHost, ["api", endpoint], what);
|
|
235
|
+
|
|
236
|
+
const graphql = <T>(
|
|
237
|
+
query: string,
|
|
238
|
+
variables: Record<string, string | number | null>,
|
|
239
|
+
what: string,
|
|
240
|
+
): ForgeResult<T> => {
|
|
241
|
+
const args = ["api", "graphql", "-f", `query=${query}`];
|
|
242
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
243
|
+
if (value === null) continue;
|
|
244
|
+
args.push(typeof value === "number" ? "-F" : "-f", `${key}=${value}`);
|
|
245
|
+
}
|
|
246
|
+
const response = apiJson<GqlResponse<T>>(
|
|
247
|
+
runner,
|
|
248
|
+
"gh",
|
|
249
|
+
apiHost,
|
|
250
|
+
args,
|
|
251
|
+
what,
|
|
252
|
+
FORGE_TIMEOUTS.api,
|
|
253
|
+
// A mutation (reply, resolve, draft) is a write: never repeated after a
|
|
254
|
+
// 403 secondary limit that may already have applied it.
|
|
255
|
+
{ graphql: true, write: /^\s*mutation\b/u.test(query) },
|
|
256
|
+
);
|
|
257
|
+
if (!response.ok) return response;
|
|
258
|
+
if (response.data.errors?.length) {
|
|
259
|
+
const message = redactText(response.data.errors[0]?.message ?? "GraphQL error").slice(0, 300);
|
|
260
|
+
return /could not resolve to a (pullrequest|repository)/iu.test(message)
|
|
261
|
+
? failure("not_found", `${what}: ${message}`)
|
|
262
|
+
: failure("failed", `${what}: ${message}`);
|
|
263
|
+
}
|
|
264
|
+
if (!response.data.data) return failure("failed", `${what}: empty GraphQL response`);
|
|
265
|
+
return success(response.data.data);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const fetchPr = (
|
|
269
|
+
number: number,
|
|
270
|
+
cursors: { threads: string | null; contexts: string | null },
|
|
271
|
+
): ForgeResult<GqlPr> => {
|
|
272
|
+
const data = graphql<{ repository: { pullRequest: GqlPr | null } | null }>(
|
|
273
|
+
PR_STATUS_QUERY,
|
|
274
|
+
{ owner, name, number, threads: cursors.threads, contexts: cursors.contexts },
|
|
275
|
+
`pull request #${number}`,
|
|
276
|
+
);
|
|
277
|
+
if (!data.ok) return data;
|
|
278
|
+
const pr = data.data.repository?.pullRequest;
|
|
279
|
+
return pr ? success(pr) : failure("not_found", `pull request #${number} was not found`);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Contexts the base branch requires: classic branch protection (readable
|
|
284
|
+
* through the branch endpoint without admin) plus branch rulesets. Null
|
|
285
|
+
* when either source could not be read.
|
|
286
|
+
*/
|
|
287
|
+
const requiredContexts = (base: string): string[] | null => {
|
|
288
|
+
const branch = rest<{
|
|
289
|
+
protected?: boolean;
|
|
290
|
+
protection?: {
|
|
291
|
+
required_status_checks?: { contexts?: string[]; checks?: Array<{ context?: string }> };
|
|
292
|
+
};
|
|
293
|
+
}>(`repos/${repo}/branches/${encodeRef(base)}`, `branch ${base}`);
|
|
294
|
+
const rules = rest<Array<{ type?: string; parameters?: Record<string, unknown> }>>(
|
|
295
|
+
`repos/${repo}/rules/branches/${encodeRef(base)}`,
|
|
296
|
+
`rules for ${base}`,
|
|
297
|
+
);
|
|
298
|
+
if (!branch.ok || !rules.ok || !Array.isArray(rules.data)) return null;
|
|
299
|
+
const contexts = new Set<string>();
|
|
300
|
+
const classic = branch.data.protection?.required_status_checks;
|
|
301
|
+
for (const context of classic?.contexts ?? []) contexts.add(context);
|
|
302
|
+
for (const check of classic?.checks ?? []) if (check.context) contexts.add(check.context);
|
|
303
|
+
for (const rule of rules.data)
|
|
304
|
+
if (rule.type === "required_status_checks") {
|
|
305
|
+
const listed = rule.parameters?.required_status_checks;
|
|
306
|
+
if (Array.isArray(listed))
|
|
307
|
+
for (const item of listed as Array<{ context?: unknown }>)
|
|
308
|
+
if (typeof item.context === "string") contexts.add(item.context);
|
|
309
|
+
}
|
|
310
|
+
return [...contexts];
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const restPr = (number: number): ForgeResult<RestPr> => {
|
|
314
|
+
const pr = rest<RestPr>(`repos/${repo}/pulls/${number}`, `pull request #${number}`);
|
|
315
|
+
if (!pr.ok) return pr;
|
|
316
|
+
return typeof pr.data.number === "number"
|
|
317
|
+
? pr
|
|
318
|
+
: failure("failed", `gh api pulls/${number} returned no pull request`);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
kind: "github",
|
|
323
|
+
apiHost,
|
|
324
|
+
repo,
|
|
325
|
+
|
|
326
|
+
identity(expected) {
|
|
327
|
+
const run = runLimited(runner, "gh", ["api", "user"], { timeoutMs: FORGE_TIMEOUTS.api });
|
|
328
|
+
if (run.status !== 0) {
|
|
329
|
+
// App/Actions installation tokens cannot read /user; the token is
|
|
330
|
+
// still valid for reads, so the check is skipped, not failed.
|
|
331
|
+
if (
|
|
332
|
+
!isRateLimited(run) &&
|
|
333
|
+
/HTTP 403|resource not accessible by integration/iu.test(run.stderr)
|
|
334
|
+
)
|
|
335
|
+
return success({
|
|
336
|
+
login: null,
|
|
337
|
+
expected,
|
|
338
|
+
matches: null,
|
|
339
|
+
note: "the credential cannot read /user (app or Actions token); identity not checked",
|
|
340
|
+
});
|
|
341
|
+
return cliFailure("gh", apiHost, run, "user", FORGE_TIMEOUTS.api);
|
|
342
|
+
}
|
|
343
|
+
let login = "";
|
|
344
|
+
try {
|
|
345
|
+
const user = JSON.parse(run.stdout) as { login?: unknown };
|
|
346
|
+
login = typeof user.login === "string" ? user.login : "";
|
|
347
|
+
} catch {
|
|
348
|
+
// handled below
|
|
349
|
+
}
|
|
350
|
+
if (!login) return failure("failed", "gh api user returned no login");
|
|
351
|
+
return success({
|
|
352
|
+
login,
|
|
353
|
+
expected,
|
|
354
|
+
matches: expected ? login.toLowerCase() === expected.toLowerCase() : null,
|
|
355
|
+
});
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
repoInfo(target) {
|
|
359
|
+
const info = rest<{ id?: number; fork?: boolean; parent?: { full_name?: string } }>(
|
|
360
|
+
`repos/${target}`,
|
|
361
|
+
`repository ${target}`,
|
|
362
|
+
);
|
|
363
|
+
if (!info.ok) return info;
|
|
364
|
+
return success({
|
|
365
|
+
id: typeof info.data.id === "number" ? info.data.id : null,
|
|
366
|
+
parent: info.data.fork && info.data.parent?.full_name ? info.data.parent.full_name : null,
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
findPr(head, match) {
|
|
371
|
+
type FindNode = {
|
|
372
|
+
number: number;
|
|
373
|
+
url: string;
|
|
374
|
+
state: string;
|
|
375
|
+
baseRefName?: string;
|
|
376
|
+
headRefName: string;
|
|
377
|
+
headRefOid: string | null;
|
|
378
|
+
headRepositoryOwner: { login: string } | null;
|
|
379
|
+
};
|
|
380
|
+
// Every page (#284 N5): an open PR past the first 20 would read as
|
|
381
|
+
// "none" and pr create would open a duplicate.
|
|
382
|
+
type FindList = {
|
|
383
|
+
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
|
|
384
|
+
nodes: FindNode[];
|
|
385
|
+
};
|
|
386
|
+
type FindPage = { repository: { pullRequests: FindList } | null };
|
|
387
|
+
const nodes: FindNode[] = [];
|
|
388
|
+
let after: string | null = null;
|
|
389
|
+
for (let page = 0; page < maxPages; page += 1) {
|
|
390
|
+
const data: ForgeResult<FindPage> = graphql<FindPage>(
|
|
391
|
+
FIND_PR_QUERY,
|
|
392
|
+
{ owner, name, head, after },
|
|
393
|
+
`pull requests for ${head}`,
|
|
394
|
+
);
|
|
395
|
+
if (!data.ok) return data;
|
|
396
|
+
const list: FindList | undefined = data.data.repository?.pullRequests;
|
|
397
|
+
nodes.push(...(list?.nodes ?? []));
|
|
398
|
+
if (!list?.pageInfo?.hasNextPage || !list.pageInfo.endCursor) break;
|
|
399
|
+
after = list.pageInfo.endCursor;
|
|
400
|
+
}
|
|
401
|
+
// A branch name alone proves nothing: anyone can open a PR from a
|
|
402
|
+
// same-named branch in their fork. The head owner or head sha must match.
|
|
403
|
+
const ours = nodes.filter(
|
|
404
|
+
(node) =>
|
|
405
|
+
node.headRefName === head &&
|
|
406
|
+
(!match.base || node.baseRefName === undefined || node.baseRefName === match.base) &&
|
|
407
|
+
((match.owner !== null &&
|
|
408
|
+
node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
|
|
409
|
+
(match.sha !== null && node.headRefOid === match.sha)),
|
|
410
|
+
);
|
|
411
|
+
const open = ours.filter((node) => node.state === "OPEN");
|
|
412
|
+
// No base named and open PRs into several bases (a git-flow release
|
|
413
|
+
// into main and its back-merge into develop): never guess.
|
|
414
|
+
if (!match.base && new Set(open.map((node) => node.baseRefName)).size > 1)
|
|
415
|
+
return failure(
|
|
416
|
+
"blocked",
|
|
417
|
+
`ambiguous_pr: ${head} has open pull requests ${open.map((node) => `#${node.number} -> ${node.baseRefName}`).join(", ")}`,
|
|
418
|
+
`pass --pr <n>, e.g. --pr ${open[0].number}`,
|
|
419
|
+
);
|
|
420
|
+
const pick = open[0] ?? ours[0];
|
|
421
|
+
if (!pick) return success(null);
|
|
422
|
+
const ref: PrRef = {
|
|
423
|
+
number: pick.number,
|
|
424
|
+
url: pick.url,
|
|
425
|
+
state: prState(pick.state),
|
|
426
|
+
headBranch: pick.headRefName,
|
|
427
|
+
headSha: pick.headRefOid,
|
|
428
|
+
};
|
|
429
|
+
return success(ref);
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
prStatus(number) {
|
|
433
|
+
const first = fetchPr(number, { threads: null, contexts: null });
|
|
434
|
+
if (!first.ok) return first;
|
|
435
|
+
const pr = first.data;
|
|
436
|
+
const threads = [...pr.reviewThreads.nodes];
|
|
437
|
+
const rollup = rollupForHead(pr.commits.nodes[0]?.commit, pr.headRefOid);
|
|
438
|
+
const contexts = [...(rollup?.contexts.nodes ?? [])];
|
|
439
|
+
let threadPage = pr.reviewThreads.pageInfo;
|
|
440
|
+
let contextPage = rollup?.contexts.pageInfo ?? { hasNextPage: false, endCursor: null };
|
|
441
|
+
let pages = 1;
|
|
442
|
+
// Each follow-up query advances whichever list still has pages.
|
|
443
|
+
while ((threadPage.hasNextPage || contextPage.hasNextPage) && pages < maxPages) {
|
|
444
|
+
pages += 1;
|
|
445
|
+
const next = fetchPr(number, {
|
|
446
|
+
threads: threadPage.hasNextPage ? threadPage.endCursor : null,
|
|
447
|
+
contexts: contextPage.hasNextPage ? contextPage.endCursor : null,
|
|
448
|
+
});
|
|
449
|
+
if (!next.ok) return next;
|
|
450
|
+
if (next.data.headRefOid !== pr.headRefOid)
|
|
451
|
+
return failure("failed", `pull request #${number} head moved while paging; retry`);
|
|
452
|
+
if (threadPage.hasNextPage) {
|
|
453
|
+
threads.push(...next.data.reviewThreads.nodes);
|
|
454
|
+
threadPage = next.data.reviewThreads.pageInfo;
|
|
455
|
+
}
|
|
456
|
+
const nextRollup = rollupForHead(next.data.commits.nodes[0]?.commit, pr.headRefOid);
|
|
457
|
+
if (contextPage.hasNextPage && nextRollup) {
|
|
458
|
+
contexts.push(...nextRollup.contexts.nodes);
|
|
459
|
+
contextPage = nextRollup.contexts.pageInfo;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const checks = latestPerName(
|
|
463
|
+
contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
|
|
464
|
+
);
|
|
465
|
+
const open = pr.state === "OPEN";
|
|
466
|
+
const status: ForgePrStatus = {
|
|
467
|
+
number: pr.number,
|
|
468
|
+
url: pr.url,
|
|
469
|
+
state: prState(pr.state),
|
|
470
|
+
draft: pr.isDraft,
|
|
471
|
+
base: pr.baseRefName,
|
|
472
|
+
baseSha: pr.baseRef?.target?.oid ?? pr.baseRefOid,
|
|
473
|
+
head: { branch: pr.headRefName, sha: pr.headRefOid },
|
|
474
|
+
mergeable:
|
|
475
|
+
pr.mergeable === "MERGEABLE" ? "yes" : pr.mergeable === "CONFLICTING" ? "no" : "unknown",
|
|
476
|
+
conflicts: pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY",
|
|
477
|
+
// BEHIND: branch protection requires an up-to-date head and it is not.
|
|
478
|
+
rebaseRequired: pr.mergeStateStatus === "BEHIND",
|
|
479
|
+
reviewDecision: pr.reviewDecision?.toLowerCase() ?? null,
|
|
480
|
+
mergeState: pr.mergeStateStatus?.toLowerCase() ?? null,
|
|
481
|
+
inMergeQueue: pr.isInMergeQueue === true,
|
|
482
|
+
checks,
|
|
483
|
+
requiredContexts: open ? requiredContexts(pr.baseRefName) : null,
|
|
484
|
+
threads: threads.filter((node) => !node.isResolved).map(threadFrom),
|
|
485
|
+
truncated: threadPage.hasNextPage || contextPage.hasNextPage,
|
|
486
|
+
};
|
|
487
|
+
return success(status);
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
branchChecks(branch) {
|
|
491
|
+
const contexts: GqlContext[] = [];
|
|
492
|
+
let sha: string | null = null;
|
|
493
|
+
let cursor: string | null = null;
|
|
494
|
+
for (let page = 1; page <= maxPages; page += 1) {
|
|
495
|
+
const data: ForgeResult<BranchChecksData> = graphql<BranchChecksData>(
|
|
496
|
+
BRANCH_CHECKS_QUERY,
|
|
497
|
+
{ owner, name, ref: `refs/heads/${branch}`, contexts: cursor },
|
|
498
|
+
`checks on ${branch}`,
|
|
499
|
+
);
|
|
500
|
+
if (!data.ok) return data;
|
|
501
|
+
const commit: NonNullable<BranchChecksData["repository"]>["object"] =
|
|
502
|
+
data.data.repository?.object ?? null;
|
|
503
|
+
if (!commit) return failure("not_found", `branch ${branch} was not found`);
|
|
504
|
+
sha = commit.oid ?? sha;
|
|
505
|
+
const rollup: { contexts: Page<GqlContext> } | null = commit.statusCheckRollup;
|
|
506
|
+
if (!rollup) break;
|
|
507
|
+
contexts.push(...rollup.contexts.nodes);
|
|
508
|
+
if (!rollup.contexts.pageInfo.hasNextPage) break;
|
|
509
|
+
cursor = rollup.contexts.pageInfo.endCursor;
|
|
510
|
+
}
|
|
511
|
+
const required = requiredContexts(branch);
|
|
512
|
+
const checks = latestPerName(
|
|
513
|
+
contexts.map(checkFromContext).filter((check): check is ForgeCheck => check !== null),
|
|
514
|
+
).map((check) =>
|
|
515
|
+
required ? { ...check, required: required.includes(check.context) } : check,
|
|
516
|
+
);
|
|
517
|
+
return success({ sha, checks, requiredKnown: required !== null });
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
jobLogTail(job, lines) {
|
|
521
|
+
const text = apiText(
|
|
522
|
+
runner,
|
|
523
|
+
"gh",
|
|
524
|
+
apiHost,
|
|
525
|
+
["api", `repos/${repo}/actions/jobs/${job.jobId}/logs`],
|
|
526
|
+
`job ${job.jobId} log`,
|
|
527
|
+
FORGE_TIMEOUTS.log,
|
|
528
|
+
);
|
|
529
|
+
return text.ok ? success(logTail(text.data, lines)) : text;
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
rerun(target) {
|
|
533
|
+
const endpoint =
|
|
534
|
+
target.kind === "failed_in_run"
|
|
535
|
+
? `repos/${repo}/actions/runs/${target.runId}/rerun-failed-jobs`
|
|
536
|
+
: `repos/${repo}/actions/jobs/${target.jobId}/rerun`;
|
|
537
|
+
const what =
|
|
538
|
+
target.kind === "failed_in_run"
|
|
539
|
+
? `rerun of run ${target.runId}`
|
|
540
|
+
: `rerun of job ${target.jobId}`;
|
|
541
|
+
const run = apiText(
|
|
542
|
+
runner,
|
|
543
|
+
"gh",
|
|
544
|
+
apiHost,
|
|
545
|
+
["api", "-X", "POST", endpoint],
|
|
546
|
+
what,
|
|
547
|
+
FORGE_TIMEOUTS.api,
|
|
548
|
+
);
|
|
549
|
+
return run.ok ? success(undefined) : run;
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
createPr(input) {
|
|
553
|
+
// Cross-repository PRs name the head as owner:branch.
|
|
554
|
+
const headOwner = input.headRepo.split("/")[0];
|
|
555
|
+
const head = input.headRepo === repo ? input.head : `${headOwner}:${input.head}`;
|
|
556
|
+
const created = apiWrite<{
|
|
557
|
+
number?: number;
|
|
558
|
+
html_url?: string;
|
|
559
|
+
state?: string;
|
|
560
|
+
merged?: boolean;
|
|
561
|
+
head?: { ref?: string; sha?: string };
|
|
562
|
+
}>(
|
|
563
|
+
runner,
|
|
564
|
+
"gh",
|
|
565
|
+
apiHost,
|
|
566
|
+
[
|
|
567
|
+
"api",
|
|
568
|
+
"-X",
|
|
569
|
+
"POST",
|
|
570
|
+
`repos/${repo}/pulls`,
|
|
571
|
+
"-f",
|
|
572
|
+
`title=${input.title}`,
|
|
573
|
+
"-f",
|
|
574
|
+
`head=${head}`,
|
|
575
|
+
"-f",
|
|
576
|
+
`base=${input.base}`,
|
|
577
|
+
"-f",
|
|
578
|
+
`body=${input.body}`,
|
|
579
|
+
"-F",
|
|
580
|
+
`draft=${input.draft ? "true" : "false"}`,
|
|
581
|
+
],
|
|
582
|
+
`pull request for ${input.head}`,
|
|
583
|
+
);
|
|
584
|
+
if (!created.ok) return created;
|
|
585
|
+
const value = created.data;
|
|
586
|
+
if (typeof value.number !== "number" || typeof value.html_url !== "string")
|
|
587
|
+
return failure("failed", "gh api pulls returned no pull request number");
|
|
588
|
+
return success({
|
|
589
|
+
number: value.number,
|
|
590
|
+
url: value.html_url,
|
|
591
|
+
state: value.merged ? "merged" : value.state === "closed" ? "closed" : "open",
|
|
592
|
+
headBranch: value.head?.ref ?? input.head,
|
|
593
|
+
headSha: value.head?.sha ?? null,
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
merge(number, mergeOptions) {
|
|
598
|
+
const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
|
|
599
|
+
// sha= is GitHub's atomic head guard: 409 when the head moved.
|
|
600
|
+
const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
|
|
601
|
+
runner,
|
|
602
|
+
"gh",
|
|
603
|
+
apiHost,
|
|
604
|
+
[
|
|
605
|
+
"api",
|
|
606
|
+
"-X",
|
|
607
|
+
"PUT",
|
|
608
|
+
`repos/${repo}/pulls/${number}/merge`,
|
|
609
|
+
...(squash
|
|
610
|
+
? ["--input", "-"]
|
|
611
|
+
: ["-f", `sha=${mergeOptions.sha}`, "-f", `merge_method=${mergeOptions.method}`]),
|
|
612
|
+
],
|
|
613
|
+
`merge of pull request #${number}`,
|
|
614
|
+
undefined,
|
|
615
|
+
// The squash message rides stdin: argv has a size limit (E2BIG).
|
|
616
|
+
squash
|
|
617
|
+
? JSON.stringify({
|
|
618
|
+
sha: mergeOptions.sha,
|
|
619
|
+
merge_method: mergeOptions.method,
|
|
620
|
+
commit_title: squash.subject,
|
|
621
|
+
commit_message: squash.body,
|
|
622
|
+
})
|
|
623
|
+
: undefined,
|
|
624
|
+
);
|
|
625
|
+
if (!merged.ok) return merged;
|
|
626
|
+
if (merged.data.merged === false)
|
|
627
|
+
return failure(
|
|
628
|
+
"blocked",
|
|
629
|
+
`merge of pull request #${number} refused: ${redactText(merged.data.message ?? "not merged").slice(0, 200)}`,
|
|
630
|
+
);
|
|
631
|
+
return success({ mergeSha: typeof merged.data.sha === "string" ? merged.data.sha : null });
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
updateBase(number, base) {
|
|
635
|
+
const updated = apiWrite<unknown>(
|
|
636
|
+
runner,
|
|
637
|
+
"gh",
|
|
638
|
+
apiHost,
|
|
639
|
+
["api", "-X", "PATCH", `repos/${repo}/pulls/${number}`, "-f", `base=${base}`],
|
|
640
|
+
`retarget of pull request #${number}`,
|
|
641
|
+
);
|
|
642
|
+
return updated.ok ? success(undefined) : updated;
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
prMeta(number) {
|
|
646
|
+
const pr = restPr(number);
|
|
647
|
+
if (!pr.ok) return pr;
|
|
648
|
+
const value = pr.data;
|
|
649
|
+
const meta: PrMeta = {
|
|
650
|
+
number,
|
|
651
|
+
url: value.html_url ?? "",
|
|
652
|
+
state: value.merged ? "merged" : value.state === "closed" ? "closed" : "open",
|
|
653
|
+
draft: value.draft === true,
|
|
654
|
+
title: value.title ?? "",
|
|
655
|
+
base: value.base?.ref ?? "",
|
|
656
|
+
headBranch: value.head?.ref ?? "",
|
|
657
|
+
author: value.user?.login ?? null,
|
|
658
|
+
// A deleted fork has no head repo: not ours, so a fork.
|
|
659
|
+
fork:
|
|
660
|
+
value.base?.repo?.full_name === undefined
|
|
661
|
+
? null
|
|
662
|
+
: value.head?.repo?.full_name?.toLowerCase() !==
|
|
663
|
+
value.base.repo.full_name.toLowerCase(),
|
|
664
|
+
};
|
|
665
|
+
return success(meta);
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
openPrsInto(branch) {
|
|
669
|
+
const list = rest<Array<{ number?: number }>>(
|
|
670
|
+
`repos/${repo}/pulls?state=open&base=${encodeURIComponent(branch)}&per_page=100`,
|
|
671
|
+
`open pull requests into ${branch}`,
|
|
672
|
+
);
|
|
673
|
+
if (!list.ok) return list;
|
|
674
|
+
if (!Array.isArray(list.data)) return failure("failed", "pull request list is not a list");
|
|
675
|
+
return success(list.data.flatMap((pr) => (typeof pr.number === "number" ? [pr.number] : [])));
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
setDraft(number, draft) {
|
|
679
|
+
const pr = restPr(number);
|
|
680
|
+
if (!pr.ok) return pr;
|
|
681
|
+
if (!pr.data.node_id) return failure("failed", `pull request #${number} has no node id`);
|
|
682
|
+
const done = graphql<unknown>(
|
|
683
|
+
draft ? DRAFT_MUTATION : READY_MUTATION,
|
|
684
|
+
{ id: pr.data.node_id },
|
|
685
|
+
draft ? `draft conversion of pull request #${number}` : `ready of pull request #${number}`,
|
|
686
|
+
);
|
|
687
|
+
return done.ok ? success(undefined) : done;
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
editPr(number, edit) {
|
|
691
|
+
const write = (args: string[], what: string): ForgeResult<unknown> =>
|
|
692
|
+
apiWrite<unknown>(runner, "gh", apiHost, ["api", ...args], what);
|
|
693
|
+
const fields: string[] = [];
|
|
694
|
+
if (edit.title !== undefined) fields.push("-f", `title=${edit.title}`);
|
|
695
|
+
if (edit.body !== undefined) fields.push("-f", `body=${edit.body}`);
|
|
696
|
+
if (edit.base !== undefined) fields.push("-f", `base=${edit.base}`);
|
|
697
|
+
if (fields.length) {
|
|
698
|
+
const patched = write(
|
|
699
|
+
["-X", "PATCH", `repos/${repo}/pulls/${number}`, ...fields],
|
|
700
|
+
`edit of pull request #${number}`,
|
|
701
|
+
);
|
|
702
|
+
if (!patched.ok) return patched;
|
|
703
|
+
}
|
|
704
|
+
if (edit.addLabels.length) {
|
|
705
|
+
const added = write(
|
|
706
|
+
[
|
|
707
|
+
"-X",
|
|
708
|
+
"POST",
|
|
709
|
+
`repos/${repo}/issues/${number}/labels`,
|
|
710
|
+
...edit.addLabels.flatMap((label) => ["-f", `labels[]=${label}`]),
|
|
711
|
+
],
|
|
712
|
+
`labels of pull request #${number}`,
|
|
713
|
+
);
|
|
714
|
+
if (!added.ok) return added;
|
|
715
|
+
}
|
|
716
|
+
for (const label of edit.removeLabels) {
|
|
717
|
+
const removed = write(
|
|
718
|
+
["-X", "DELETE", `repos/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`],
|
|
719
|
+
`label ${label} of pull request #${number}`,
|
|
720
|
+
);
|
|
721
|
+
// A label the PR does not carry is already removed.
|
|
722
|
+
if (!removed.ok && removed.code !== "not_found") return removed;
|
|
723
|
+
}
|
|
724
|
+
if (edit.addReviewers.length) {
|
|
725
|
+
// org/team requests a team; anything else is a login.
|
|
726
|
+
const requested = write(
|
|
727
|
+
[
|
|
728
|
+
"-X",
|
|
729
|
+
"POST",
|
|
730
|
+
`repos/${repo}/pulls/${number}/requested_reviewers`,
|
|
731
|
+
...edit.addReviewers.flatMap((reviewer) =>
|
|
732
|
+
reviewer.includes("/")
|
|
733
|
+
? ["-f", `team_reviewers[]=${reviewer.split("/").pop()}`]
|
|
734
|
+
: ["-f", `reviewers[]=${reviewer}`],
|
|
735
|
+
),
|
|
736
|
+
],
|
|
737
|
+
`reviewers of pull request #${number}`,
|
|
738
|
+
);
|
|
739
|
+
if (!requested.ok) return requested;
|
|
740
|
+
}
|
|
741
|
+
return success(undefined);
|
|
742
|
+
},
|
|
743
|
+
|
|
744
|
+
replyThread(number, thread, body) {
|
|
745
|
+
const replied = graphql<{
|
|
746
|
+
addPullRequestReviewThreadReply?: { comment?: { url?: string } | null } | null;
|
|
747
|
+
}>(REPLY_MUTATION, { thread, body }, `reply on pull request #${number}`);
|
|
748
|
+
if (!replied.ok) return replied;
|
|
749
|
+
return success({
|
|
750
|
+
url: safeUrl(replied.data.addPullRequestReviewThreadReply?.comment?.url ?? null),
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
resolveThread(number, thread) {
|
|
755
|
+
const resolved = graphql<{
|
|
756
|
+
resolveReviewThread?: { thread?: { isResolved?: boolean } | null } | null;
|
|
757
|
+
}>(RESOLVE_MUTATION, { thread }, `resolve on pull request #${number}`);
|
|
758
|
+
if (!resolved.ok) return resolved;
|
|
759
|
+
return resolved.data.resolveReviewThread?.thread?.isResolved === true
|
|
760
|
+
? success(undefined)
|
|
761
|
+
: failure("failed", `thread ${thread} on pull request #${number} is still unresolved`);
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
threadState(number, thread) {
|
|
765
|
+
const read = graphql<{
|
|
766
|
+
node?: { isResolved?: boolean; pullRequest?: { number?: number } | null } | null;
|
|
767
|
+
}>(THREAD_QUERY, { thread }, `thread ${thread}`);
|
|
768
|
+
if (!read.ok)
|
|
769
|
+
return read.code === "not_found" || /could not resolve to a node/iu.test(read.error)
|
|
770
|
+
? success(null)
|
|
771
|
+
: read;
|
|
772
|
+
const node = read.data.node;
|
|
773
|
+
if (node?.pullRequest?.number !== number) return success(null);
|
|
774
|
+
return success(node.isResolved ? "resolved" : "open");
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
findRelease(tag) {
|
|
778
|
+
const found = rest<{ tag_name?: string; html_url?: string }>(
|
|
779
|
+
`repos/${repo}/releases/tags/${encodeRef(tag)}`,
|
|
780
|
+
`release ${tag}`,
|
|
781
|
+
);
|
|
782
|
+
if (!found.ok) return found.code === "not_found" ? success(null) : found;
|
|
783
|
+
return success({
|
|
784
|
+
tag: found.data.tag_name ?? tag,
|
|
785
|
+
url: safeUrl(found.data.html_url ?? null),
|
|
786
|
+
});
|
|
787
|
+
},
|
|
788
|
+
|
|
789
|
+
createRelease(input) {
|
|
790
|
+
// The notes ride stdin: argv has a size limit (E2BIG).
|
|
791
|
+
const created = apiWrite<{ tag_name?: string; html_url?: string }>(
|
|
792
|
+
runner,
|
|
793
|
+
"gh",
|
|
794
|
+
apiHost,
|
|
795
|
+
["api", "-X", "POST", `repos/${repo}/releases`, "--input", "-"],
|
|
796
|
+
`release ${input.tag}`,
|
|
797
|
+
undefined,
|
|
798
|
+
JSON.stringify({ tag_name: input.tag, name: input.name, body: input.body }),
|
|
799
|
+
);
|
|
800
|
+
if (!created.ok) return created;
|
|
801
|
+
return success({
|
|
802
|
+
tag: created.data.tag_name ?? input.tag,
|
|
803
|
+
url: safeUrl(created.data.html_url ?? null),
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
issueComments(n) {
|
|
808
|
+
const listed = rest<Array<{ body?: unknown }>>(
|
|
809
|
+
`repos/${repo}/issues/${n}/comments?per_page=100`,
|
|
810
|
+
`comments on issue #${n}`,
|
|
811
|
+
);
|
|
812
|
+
if (!listed.ok) return listed;
|
|
813
|
+
if (!Array.isArray(listed.data))
|
|
814
|
+
return failure("failed", `gh api issue #${n} comments returned no list`);
|
|
815
|
+
return success(
|
|
816
|
+
listed.data.flatMap((comment) => (typeof comment.body === "string" ? [comment.body] : [])),
|
|
817
|
+
);
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
commentIssue(n, body) {
|
|
821
|
+
const posted = apiWrite<unknown>(
|
|
822
|
+
runner,
|
|
823
|
+
"gh",
|
|
824
|
+
apiHost,
|
|
825
|
+
["api", "-X", "POST", `repos/${repo}/issues/${n}/comments`, "-f", `body=${body}`],
|
|
826
|
+
`comment on issue #${n}`,
|
|
827
|
+
);
|
|
828
|
+
return posted.ok ? success(undefined) : posted;
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
}
|