@theholocron/holocron-plugin-github 2.0.0-alpha.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 +48 -0
- package/dist/index.d.mts +228 -0
- package/dist/index.mjs +660 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Newton Koumantzelis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `@theholocron/holocron-plugin-github`
|
|
2
|
+
|
|
3
|
+
GitHub plugin for [Holocron](../cli). Implements five capabilities
|
|
4
|
+
against the GitHub REST API:
|
|
5
|
+
|
|
6
|
+
| Capability | What this plugin does |
|
|
7
|
+
| -------------- | --------------------------------------------------------- |
|
|
8
|
+
| `source` | Repos, rulesets, repo settings, security toggles, workflow files |
|
|
9
|
+
| `ci` | Workflow run history + status |
|
|
10
|
+
| `secrets` | GH Actions secrets (repo + environment + organization) |
|
|
11
|
+
| `environments` | Named deployment environments (reviewers, wait timers) |
|
|
12
|
+
| `issues` | GitHub Issues as a tracker (with lifecycle slots) |
|
|
13
|
+
|
|
14
|
+
## Auth
|
|
15
|
+
|
|
16
|
+
The plugin requires a GitHub token resolved in this order:
|
|
17
|
+
|
|
18
|
+
1. `--token <PAT>` flag on the `holocron` invocation
|
|
19
|
+
2. `HOLOCRON_GH_TOKEN` env var
|
|
20
|
+
3. `GITHUB_TOKEN` env var (auto-injected in GitHub Actions runners)
|
|
21
|
+
|
|
22
|
+
If none are set, the plugin throws a clear error pointing at the
|
|
23
|
+
options above. There is **no** `gh auth token` fallback because the
|
|
24
|
+
scopes that local `gh` auth has are usually narrower than what
|
|
25
|
+
admin-level holocron commands need (rulesets, repo settings, security
|
|
26
|
+
toggles, etc.) — silent fallback would surface as mysterious 403s.
|
|
27
|
+
|
|
28
|
+
## Config
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
// holocron.config.json
|
|
32
|
+
{
|
|
33
|
+
"providers": {
|
|
34
|
+
"source": "github",
|
|
35
|
+
"ci": "github",
|
|
36
|
+
"secrets": "github",
|
|
37
|
+
"environments": "github",
|
|
38
|
+
"issues": ["github", { "labels": { "inProgress": "status:in-progress", "inReview": "status:in-review" } }]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
**v0.0.0 — scaffolded.** Implementations are being ported from
|
|
46
|
+
[`rando-id/rando.id`](https://github.com/rando-id/rando.id)'s
|
|
47
|
+
`packages/cli/src/adapters/` per the migration plan in
|
|
48
|
+
`.notes/tech-architecture.spec.md`.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Auth, Ci, CiRun, CiRunFilter, Environment, Environments, Issue, IssueSearchFilter, Issues, LifecycleResult, LifecycleSlot, RepoRef, RepoSettings, Ruleset, SecretScope, Secrets, Source, TrackerDoctorReport, TrackerUser } from "@theholocron/cli";
|
|
2
|
+
|
|
3
|
+
//#region src/auth.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Token resolution for the GitHub plugin. See README §Auth.
|
|
6
|
+
*
|
|
7
|
+
* Resolution order:
|
|
8
|
+
* 1. explicit `token` argument (from `--token` flag)
|
|
9
|
+
* 2. HOLOCRON_GH_TOKEN env var (preferred over GITHUB_TOKEN — clearer intent)
|
|
10
|
+
* 3. GITHUB_TOKEN env var (auto-injected in GH Actions)
|
|
11
|
+
*
|
|
12
|
+
* No `gh auth token` fallback by design — it usually has narrower
|
|
13
|
+
* scopes than admin commands need.
|
|
14
|
+
*/
|
|
15
|
+
declare class AuthError extends Error {
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
interface ResolveTokenInput {
|
|
19
|
+
/** From `--token` CLI flag. */
|
|
20
|
+
cliToken?: string;
|
|
21
|
+
/** Env vars; passed in for testability. Defaults to `process.env`. */
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
}
|
|
24
|
+
declare function resolveToken(input?: ResolveTokenInput): string;
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/rest.d.ts
|
|
27
|
+
/**
|
|
28
|
+
* Thin REST wrapper around api.github.com.
|
|
29
|
+
*
|
|
30
|
+
* Adapted from rando-id/rando.id `packages/cli/src/adapters/gh-rest.ts`
|
|
31
|
+
* `request<T>` method into a standalone class so each capability can
|
|
32
|
+
* hold a reference and share the same auth/error handling.
|
|
33
|
+
*
|
|
34
|
+
* Errors throw `ProviderApiError` from `@theholocron/cli`, which the
|
|
35
|
+
* orchestrator catches to soft-skip rather than abort the whole pipeline.
|
|
36
|
+
*/
|
|
37
|
+
interface RestClientOptions {
|
|
38
|
+
token: string;
|
|
39
|
+
fetch?: typeof fetch;
|
|
40
|
+
baseUrl?: string;
|
|
41
|
+
}
|
|
42
|
+
interface RequestOptions {
|
|
43
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
|
44
|
+
body?: unknown;
|
|
45
|
+
/** Skip JSON parse when true (204 No Content endpoints). */
|
|
46
|
+
expectNoContent?: boolean;
|
|
47
|
+
}
|
|
48
|
+
declare class GitHubRestClient {
|
|
49
|
+
private readonly token;
|
|
50
|
+
private readonly fetchImpl;
|
|
51
|
+
readonly baseUrl: string;
|
|
52
|
+
constructor(opts: RestClientOptions);
|
|
53
|
+
request<T>(path: string, opts?: RequestOptions): Promise<T>;
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/sodium.d.ts
|
|
57
|
+
/**
|
|
58
|
+
* Encrypt `value` for the given GitHub-provided base64 public key.
|
|
59
|
+
* Returns the base64 ciphertext to send as `encrypted_value`.
|
|
60
|
+
*/
|
|
61
|
+
declare function encryptSecret(publicKeyBase64: string, value: string): Promise<string>;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/capabilities/source.d.ts
|
|
64
|
+
interface SourceOptions {
|
|
65
|
+
repo: string;
|
|
66
|
+
/** Absolute path to the repo root. Workflow file ops are scoped here. */
|
|
67
|
+
repoRoot: string;
|
|
68
|
+
}
|
|
69
|
+
declare class GitHubSource implements Source {
|
|
70
|
+
private readonly rest;
|
|
71
|
+
readonly key: "source";
|
|
72
|
+
readonly providerName = "github";
|
|
73
|
+
private readonly owner;
|
|
74
|
+
private readonly name;
|
|
75
|
+
private readonly repoPath;
|
|
76
|
+
private readonly workflowDir;
|
|
77
|
+
constructor(rest: GitHubRestClient, opts: SourceOptions);
|
|
78
|
+
whoami(): Promise<{
|
|
79
|
+
login: string;
|
|
80
|
+
}>;
|
|
81
|
+
getRepo(): Promise<RepoRef>;
|
|
82
|
+
listRulesets(): Promise<Ruleset[]>;
|
|
83
|
+
createRuleset(payload: Record<string, unknown>): Promise<Ruleset>;
|
|
84
|
+
updateRuleset(id: number, payload: Record<string, unknown>): Promise<Ruleset>;
|
|
85
|
+
updateRepoSettings(settings: RepoSettings): Promise<void>;
|
|
86
|
+
enableVulnerabilityAlerts(): Promise<void>;
|
|
87
|
+
enableAutomatedSecurityFixes(): Promise<void>;
|
|
88
|
+
enableSecretScanning(): Promise<void>;
|
|
89
|
+
enablePrivateVulnerabilityReporting(): Promise<void>;
|
|
90
|
+
listWorkflowFiles(): Promise<string[]>;
|
|
91
|
+
readWorkflowFile(name: string): Promise<string | null>;
|
|
92
|
+
writeWorkflowFile(name: string, contents: string): Promise<void>;
|
|
93
|
+
removeWorkflowFile(name: string): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/capabilities/secrets.d.ts
|
|
97
|
+
interface SecretsOptions {
|
|
98
|
+
repo: string;
|
|
99
|
+
}
|
|
100
|
+
declare class GitHubSecrets implements Secrets {
|
|
101
|
+
private readonly rest;
|
|
102
|
+
readonly key: "secrets";
|
|
103
|
+
readonly providerName = "github";
|
|
104
|
+
private readonly repoBase;
|
|
105
|
+
constructor(rest: GitHubRestClient, opts: SecretsOptions);
|
|
106
|
+
listSecrets(scope: SecretScope): Promise<string[]>;
|
|
107
|
+
setSecret(scope: SecretScope, name: string, value: string): Promise<void>;
|
|
108
|
+
deleteSecret(scope: SecretScope, name: string): Promise<void>;
|
|
109
|
+
/** Endpoint root for the given scope. */
|
|
110
|
+
private scopeBase;
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/capabilities/environments.d.ts
|
|
114
|
+
interface EnvironmentsOptions {
|
|
115
|
+
repo: string;
|
|
116
|
+
}
|
|
117
|
+
declare class GitHubEnvironments implements Environments {
|
|
118
|
+
private readonly rest;
|
|
119
|
+
readonly key: "environments";
|
|
120
|
+
readonly providerName = "github";
|
|
121
|
+
private readonly base;
|
|
122
|
+
constructor(rest: GitHubRestClient, opts: EnvironmentsOptions);
|
|
123
|
+
listEnvironments(): Promise<Environment[]>;
|
|
124
|
+
upsertEnvironment(env: Environment): Promise<void>;
|
|
125
|
+
deleteEnvironment(name: string): Promise<void>;
|
|
126
|
+
private flattenReviewers;
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/capabilities/ci.d.ts
|
|
130
|
+
interface CiOptions {
|
|
131
|
+
repo: string;
|
|
132
|
+
}
|
|
133
|
+
declare class GitHubCi implements Ci {
|
|
134
|
+
private readonly rest;
|
|
135
|
+
readonly key: "ci";
|
|
136
|
+
readonly providerName = "github";
|
|
137
|
+
private readonly base;
|
|
138
|
+
constructor(rest: GitHubRestClient, opts: CiOptions);
|
|
139
|
+
listRuns(filter?: CiRunFilter): Promise<CiRun[]>;
|
|
140
|
+
getRun(id: string | number): Promise<CiRun>;
|
|
141
|
+
/**
|
|
142
|
+
* GitHub reports run state as `status` plus `conclusion` (the latter
|
|
143
|
+
* is set once `status === 'completed'`). Holocron's `CiRunStatus`
|
|
144
|
+
* collapses these onto one axis — when complete, the conclusion is
|
|
145
|
+
* the meaningful value.
|
|
146
|
+
*/
|
|
147
|
+
private mapRun;
|
|
148
|
+
private mapConclusion;
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/capabilities/issues.d.ts
|
|
152
|
+
interface IssuesOptions {
|
|
153
|
+
repo: string;
|
|
154
|
+
/** Lifecycle slot → status label name. */
|
|
155
|
+
labels: {
|
|
156
|
+
inProgress: string;
|
|
157
|
+
inReview: string;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
declare class GitHubIssues implements Issues {
|
|
161
|
+
private readonly rest;
|
|
162
|
+
readonly key: "issues";
|
|
163
|
+
readonly providerName = "github";
|
|
164
|
+
private readonly owner;
|
|
165
|
+
private readonly repoName;
|
|
166
|
+
private readonly base;
|
|
167
|
+
private readonly labels;
|
|
168
|
+
constructor(rest: GitHubRestClient, opts: IssuesOptions);
|
|
169
|
+
getMyself(): Promise<TrackerUser>;
|
|
170
|
+
search(filter: IssueSearchFilter): Promise<Issue[]>;
|
|
171
|
+
get(key: string): Promise<Issue>;
|
|
172
|
+
create(input: {
|
|
173
|
+
summary: string;
|
|
174
|
+
body?: string;
|
|
175
|
+
labels?: string[];
|
|
176
|
+
milestone?: string;
|
|
177
|
+
}): Promise<{
|
|
178
|
+
key: string;
|
|
179
|
+
}>;
|
|
180
|
+
transition(key: string, slot: LifecycleSlot): Promise<LifecycleResult>;
|
|
181
|
+
comment(key: string, body: string): Promise<void>;
|
|
182
|
+
doctor(): Promise<TrackerDoctorReport>;
|
|
183
|
+
private resolveMilestone;
|
|
184
|
+
private removeStatusLabels;
|
|
185
|
+
private mapIssue;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/index.d.ts
|
|
189
|
+
interface GitHubPluginOptions extends ResolveTokenInput {
|
|
190
|
+
/** "owner/name" — e.g., "theholocron/holocron". Required. */
|
|
191
|
+
repo: string;
|
|
192
|
+
/** Absolute path to the working repo root. Used by `source`'s
|
|
193
|
+
* workflow-file methods. Defaults to `process.cwd()`. */
|
|
194
|
+
repoRoot?: string;
|
|
195
|
+
/** Lifecycle slot → label name. Used by the `issues` capability. */
|
|
196
|
+
labels?: {
|
|
197
|
+
inProgress: string;
|
|
198
|
+
inReview: string;
|
|
199
|
+
};
|
|
200
|
+
/** Override base URL for tests. Defaults to https://api.github.com. */
|
|
201
|
+
baseUrl?: string;
|
|
202
|
+
/** Override `fetch` for tests. Defaults to global `fetch`. */
|
|
203
|
+
fetch?: typeof fetch;
|
|
204
|
+
}
|
|
205
|
+
interface PluginContext {
|
|
206
|
+
options: GitHubPluginOptions;
|
|
207
|
+
rest: GitHubRestClient;
|
|
208
|
+
repo: string;
|
|
209
|
+
repoRoot: string;
|
|
210
|
+
}
|
|
211
|
+
declare function createContext(options: GitHubPluginOptions): PluginContext;
|
|
212
|
+
declare function source(ctx: PluginContext): Source;
|
|
213
|
+
declare function secrets(ctx: PluginContext): Secrets;
|
|
214
|
+
declare function environments(ctx: PluginContext): Environments;
|
|
215
|
+
declare function ci(ctx: PluginContext): Ci;
|
|
216
|
+
declare function issues(ctx: PluginContext): Issues;
|
|
217
|
+
declare function createPlugin(options: GitHubPluginOptions): {
|
|
218
|
+
name: string;
|
|
219
|
+
capabilities: {
|
|
220
|
+
source: () => Source;
|
|
221
|
+
ci: () => Ci;
|
|
222
|
+
secrets: () => Secrets;
|
|
223
|
+
environments: () => Environments;
|
|
224
|
+
issues: () => Issues;
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
export { type Auth, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubPluginOptions, GitHubRestClient, GitHubSecrets, GitHubSource, PluginContext, ResolveTokenInput, ci, createContext, createPlugin, encryptSecret, environments, issues, resolveToken, secrets, source };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ProviderApiError } from "@theholocron/cli";
|
|
5
|
+
//#region src/auth.ts
|
|
6
|
+
/**
|
|
7
|
+
* Token resolution for the GitHub plugin. See README §Auth.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order:
|
|
10
|
+
* 1. explicit `token` argument (from `--token` flag)
|
|
11
|
+
* 2. HOLOCRON_GH_TOKEN env var (preferred over GITHUB_TOKEN — clearer intent)
|
|
12
|
+
* 3. GITHUB_TOKEN env var (auto-injected in GH Actions)
|
|
13
|
+
*
|
|
14
|
+
* No `gh auth token` fallback by design — it usually has narrower
|
|
15
|
+
* scopes than admin commands need.
|
|
16
|
+
*/
|
|
17
|
+
var AuthError = class extends Error {
|
|
18
|
+
name = "AuthError";
|
|
19
|
+
};
|
|
20
|
+
function resolveToken(input = {}) {
|
|
21
|
+
const env = input.env ?? process.env;
|
|
22
|
+
const token = input.cliToken || env.HOLOCRON_GH_TOKEN || env.GITHUB_TOKEN;
|
|
23
|
+
if (!token) throw new AuthError("no GitHub token found. Pass --token <PAT>, or set HOLOCRON_GH_TOKEN / GITHUB_TOKEN.");
|
|
24
|
+
return token;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/repo.ts
|
|
28
|
+
var RepoError = class extends Error {
|
|
29
|
+
name = "RepoError";
|
|
30
|
+
};
|
|
31
|
+
function parseRepo(repo) {
|
|
32
|
+
if (!repo) throw new RepoError("repo is required; pass { repo: \"owner/name\" } in plugin options");
|
|
33
|
+
const [owner, name] = repo.split("/");
|
|
34
|
+
if (!owner || !name) throw new RepoError(`invalid repo "${repo}" — expected "owner/name"`);
|
|
35
|
+
return {
|
|
36
|
+
owner,
|
|
37
|
+
name
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/capabilities/ci.ts
|
|
42
|
+
var GitHubCi = class {
|
|
43
|
+
rest;
|
|
44
|
+
key = "ci";
|
|
45
|
+
providerName = "github";
|
|
46
|
+
base;
|
|
47
|
+
constructor(rest, opts) {
|
|
48
|
+
this.rest = rest;
|
|
49
|
+
const { owner, name } = parseRepo(opts.repo);
|
|
50
|
+
this.base = `/repos/${owner}/${name}/actions/runs`;
|
|
51
|
+
}
|
|
52
|
+
async listRuns(filter) {
|
|
53
|
+
const params = new URLSearchParams();
|
|
54
|
+
if (filter?.branch) params.set("branch", filter.branch);
|
|
55
|
+
if (filter?.limit) params.set("per_page", String(filter.limit));
|
|
56
|
+
if (filter?.status) params.set("status", filter.status);
|
|
57
|
+
const qs = params.toString();
|
|
58
|
+
const path = qs ? `${this.base}?${qs}` : this.base;
|
|
59
|
+
return (await this.rest.request(path)).workflow_runs.map((r) => this.mapRun(r));
|
|
60
|
+
}
|
|
61
|
+
async getRun(id) {
|
|
62
|
+
const raw = await this.rest.request(`${this.base}/${id}`);
|
|
63
|
+
return this.mapRun(raw);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* GitHub reports run state as `status` plus `conclusion` (the latter
|
|
67
|
+
* is set once `status === 'completed'`). Holocron's `CiRunStatus`
|
|
68
|
+
* collapses these onto one axis — when complete, the conclusion is
|
|
69
|
+
* the meaningful value.
|
|
70
|
+
*/
|
|
71
|
+
mapRun(raw) {
|
|
72
|
+
const status = raw.status === "completed" && raw.conclusion ? this.mapConclusion(raw.conclusion) : raw.status;
|
|
73
|
+
return {
|
|
74
|
+
id: raw.id,
|
|
75
|
+
workflowName: raw.name ?? raw.display_title,
|
|
76
|
+
branch: raw.head_branch,
|
|
77
|
+
sha: raw.head_sha,
|
|
78
|
+
status,
|
|
79
|
+
url: raw.html_url,
|
|
80
|
+
startedAt: raw.created_at,
|
|
81
|
+
...raw.status === "completed" ? { completedAt: raw.updated_at } : {}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
mapConclusion(conclusion) {
|
|
85
|
+
switch (conclusion) {
|
|
86
|
+
case "success":
|
|
87
|
+
case "failure":
|
|
88
|
+
case "cancelled":
|
|
89
|
+
case "skipped": return conclusion;
|
|
90
|
+
default: return "completed";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/capabilities/environments.ts
|
|
96
|
+
var GitHubEnvironments = class {
|
|
97
|
+
rest;
|
|
98
|
+
key = "environments";
|
|
99
|
+
providerName = "github";
|
|
100
|
+
base;
|
|
101
|
+
constructor(rest, opts) {
|
|
102
|
+
this.rest = rest;
|
|
103
|
+
const { owner, name } = parseRepo(opts.repo);
|
|
104
|
+
this.base = `/repos/${owner}/${name}/environments`;
|
|
105
|
+
}
|
|
106
|
+
async listEnvironments() {
|
|
107
|
+
return (await this.rest.request(this.base)).environments.map((e) => ({
|
|
108
|
+
name: e.name,
|
|
109
|
+
waitTimer: e.wait_timer,
|
|
110
|
+
preventSelfReview: e.prevent_self_review,
|
|
111
|
+
reviewers: this.flattenReviewers(e.protection_rules)
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
async upsertEnvironment(env) {
|
|
115
|
+
const body = {};
|
|
116
|
+
if (env.waitTimer !== void 0) body.wait_timer = env.waitTimer;
|
|
117
|
+
if (env.preventSelfReview !== void 0) body.prevent_self_review = env.preventSelfReview;
|
|
118
|
+
if (env.reviewers !== void 0) body.reviewers = env.reviewers.map((r) => ({
|
|
119
|
+
type: r.type,
|
|
120
|
+
id: r.id
|
|
121
|
+
}));
|
|
122
|
+
await this.rest.request(`${this.base}/${encodeURIComponent(env.name)}`, {
|
|
123
|
+
method: "PUT",
|
|
124
|
+
body
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async deleteEnvironment(name) {
|
|
128
|
+
await this.rest.request(`${this.base}/${encodeURIComponent(name)}`, {
|
|
129
|
+
method: "DELETE",
|
|
130
|
+
expectNoContent: true
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
flattenReviewers(rules) {
|
|
134
|
+
if (!rules) return void 0;
|
|
135
|
+
const reviewersRule = rules.find((r) => r.type === "required_reviewers");
|
|
136
|
+
if (!reviewersRule?.reviewers) return void 0;
|
|
137
|
+
return reviewersRule.reviewers.map((r) => ({
|
|
138
|
+
type: r.type,
|
|
139
|
+
id: r.reviewer.id
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/capabilities/issues.ts
|
|
145
|
+
var GitHubIssues = class {
|
|
146
|
+
rest;
|
|
147
|
+
key = "issues";
|
|
148
|
+
providerName = "github";
|
|
149
|
+
owner;
|
|
150
|
+
repoName;
|
|
151
|
+
base;
|
|
152
|
+
labels;
|
|
153
|
+
constructor(rest, opts) {
|
|
154
|
+
this.rest = rest;
|
|
155
|
+
const { owner, name } = parseRepo(opts.repo);
|
|
156
|
+
this.owner = owner;
|
|
157
|
+
this.repoName = name;
|
|
158
|
+
this.base = `/repos/${owner}/${name}`;
|
|
159
|
+
this.labels = opts.labels;
|
|
160
|
+
}
|
|
161
|
+
async getMyself() {
|
|
162
|
+
return mapUser(await this.rest.request("/user"));
|
|
163
|
+
}
|
|
164
|
+
async search(filter) {
|
|
165
|
+
const params = new URLSearchParams({
|
|
166
|
+
state: filter.openOnly ? "open" : "all",
|
|
167
|
+
sort: "updated",
|
|
168
|
+
direction: "desc",
|
|
169
|
+
per_page: String(filter.limit ?? 50),
|
|
170
|
+
filter: "all"
|
|
171
|
+
});
|
|
172
|
+
if (filter.assignee === "currentUser") {
|
|
173
|
+
const me = await this.getMyself();
|
|
174
|
+
params.set("assignee", me.id);
|
|
175
|
+
} else if (filter.assignee) params.set("assignee", filter.assignee);
|
|
176
|
+
return (await this.rest.request(`${this.base}/issues?${params.toString()}`)).filter((i) => !i.pull_request).map((i) => this.mapIssue(i));
|
|
177
|
+
}
|
|
178
|
+
async get(key) {
|
|
179
|
+
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
180
|
+
const raw = await this.rest.request(`${this.base}/issues/${number}`);
|
|
181
|
+
return this.mapIssue(raw);
|
|
182
|
+
}
|
|
183
|
+
async create(input) {
|
|
184
|
+
const body = { title: input.summary };
|
|
185
|
+
if (input.body) body.body = input.body;
|
|
186
|
+
if (input.labels?.length) body.labels = input.labels;
|
|
187
|
+
if (input.milestone) body.milestone = await this.resolveMilestone(input.milestone);
|
|
188
|
+
return { key: `#${(await this.rest.request(`${this.base}/issues`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
body
|
|
191
|
+
})).number}` };
|
|
192
|
+
}
|
|
193
|
+
async transition(key, slot) {
|
|
194
|
+
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
195
|
+
const issue = await this.rest.request(`${this.base}/issues/${number}`);
|
|
196
|
+
const currentStatusLabels = issue.labels.map((l) => l.name).filter((n) => n.startsWith("status:"));
|
|
197
|
+
if (slot === "done") {
|
|
198
|
+
if (issue.state === "closed") return {
|
|
199
|
+
transitioned: false,
|
|
200
|
+
status: "closed",
|
|
201
|
+
via: "already closed"
|
|
202
|
+
};
|
|
203
|
+
await this.removeStatusLabels(number, currentStatusLabels);
|
|
204
|
+
await this.rest.request(`${this.base}/issues/${number}`, {
|
|
205
|
+
method: "PATCH",
|
|
206
|
+
body: {
|
|
207
|
+
state: "closed",
|
|
208
|
+
state_reason: "completed"
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return {
|
|
212
|
+
transitioned: true,
|
|
213
|
+
status: "closed",
|
|
214
|
+
via: "closed (completed)"
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const targetLabel = slot === "inProgress" ? this.labels.inProgress : this.labels.inReview;
|
|
218
|
+
const alreadyOpen = issue.state === "open";
|
|
219
|
+
const alreadyLabeled = currentStatusLabels.includes(targetLabel);
|
|
220
|
+
if (alreadyOpen && alreadyLabeled && currentStatusLabels.length === 1) return {
|
|
221
|
+
transitioned: false,
|
|
222
|
+
status: `open + ${targetLabel}`,
|
|
223
|
+
via: "already there"
|
|
224
|
+
};
|
|
225
|
+
if (!alreadyOpen) await this.rest.request(`${this.base}/issues/${number}`, {
|
|
226
|
+
method: "PATCH",
|
|
227
|
+
body: { state: "open" }
|
|
228
|
+
});
|
|
229
|
+
const toRemove = currentStatusLabels.filter((n) => n !== targetLabel);
|
|
230
|
+
if (toRemove.length) await this.removeStatusLabels(number, toRemove);
|
|
231
|
+
if (!alreadyLabeled) await this.rest.request(`${this.base}/issues/${number}/labels`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: { labels: [targetLabel] }
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
transitioned: true,
|
|
237
|
+
status: `open + ${targetLabel}`,
|
|
238
|
+
via: `label set to ${targetLabel}`
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async comment(key, body) {
|
|
242
|
+
const number = parseIssueNumber(key, this.owner, this.repoName);
|
|
243
|
+
await this.rest.request(`${this.base}/issues/${number}/comments`, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
body: { body }
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
async doctor() {
|
|
249
|
+
const me = await this.getMyself();
|
|
250
|
+
const repo = await this.rest.request(this.base);
|
|
251
|
+
const allLabels = await this.rest.request(`${this.base}/labels?per_page=100`);
|
|
252
|
+
const labelNames = new Set(allLabels.map((l) => l.name));
|
|
253
|
+
const statuses = [
|
|
254
|
+
{
|
|
255
|
+
name: "open (no status label)",
|
|
256
|
+
category: "open"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: `open + ${this.labels.inProgress}`,
|
|
260
|
+
category: "in-progress"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: `open + ${this.labels.inReview}`,
|
|
264
|
+
category: "in-review"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "closed",
|
|
268
|
+
category: "done"
|
|
269
|
+
}
|
|
270
|
+
];
|
|
271
|
+
const lifecycle = [
|
|
272
|
+
{
|
|
273
|
+
slot: "inProgress",
|
|
274
|
+
value: this.labels.inProgress,
|
|
275
|
+
resolved: labelNames.has(this.labels.inProgress),
|
|
276
|
+
note: labelNames.has(this.labels.inProgress) ? `label exists in ${repo.full_name}` : "(label not defined yet — auto-created on first apply)"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
slot: "inReview",
|
|
280
|
+
value: this.labels.inReview,
|
|
281
|
+
resolved: labelNames.has(this.labels.inReview),
|
|
282
|
+
note: labelNames.has(this.labels.inReview) ? `label exists in ${repo.full_name}` : "(label not defined yet — auto-created on first apply)"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
slot: "done",
|
|
286
|
+
value: "closed (state_reason=completed)",
|
|
287
|
+
resolved: true,
|
|
288
|
+
note: "(intrinsic — GitHub close-with-reason)"
|
|
289
|
+
}
|
|
290
|
+
];
|
|
291
|
+
return {
|
|
292
|
+
authedAs: `${me.displayName} (${me.emailAddress ?? me.id})`,
|
|
293
|
+
projectLabel: `Repo: ${repo.full_name}`,
|
|
294
|
+
statuses,
|
|
295
|
+
lifecycle
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async resolveMilestone(ref) {
|
|
299
|
+
if (/^\d+$/.test(ref)) return parseInt(ref, 10);
|
|
300
|
+
const match = (await this.rest.request(`${this.base}/milestones?state=all&per_page=100`)).find((m) => m.title.toLowerCase() === ref.toLowerCase());
|
|
301
|
+
if (!match) throw new Error(`Milestone "${ref}" not found in ${this.owner}/${this.repoName}. Use the numeric id or an exact title.`);
|
|
302
|
+
return match.number;
|
|
303
|
+
}
|
|
304
|
+
async removeStatusLabels(number, labels) {
|
|
305
|
+
for (const label of labels) await this.rest.request(`${this.base}/issues/${number}/labels/${encodeURIComponent(label)}`, { method: "DELETE" });
|
|
306
|
+
}
|
|
307
|
+
mapIssue(raw) {
|
|
308
|
+
const statusLabels = raw.labels.map((l) => l.name).filter((n) => n.startsWith("status:"));
|
|
309
|
+
let category = "open";
|
|
310
|
+
let statusName = raw.state === "closed" ? "closed" : "open";
|
|
311
|
+
if (raw.state === "closed") category = "done";
|
|
312
|
+
else if (statusLabels.includes(this.labels.inProgress)) {
|
|
313
|
+
category = "in-progress";
|
|
314
|
+
statusName = `open + ${this.labels.inProgress}`;
|
|
315
|
+
} else if (statusLabels.includes(this.labels.inReview)) {
|
|
316
|
+
category = "in-review";
|
|
317
|
+
statusName = `open + ${this.labels.inReview}`;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
key: `#${raw.number}`,
|
|
321
|
+
id: String(raw.id),
|
|
322
|
+
summary: raw.title,
|
|
323
|
+
...raw.body ? { body: raw.body } : {},
|
|
324
|
+
status: statusName,
|
|
325
|
+
statusCategory: category,
|
|
326
|
+
assignee: raw.assignee ? mapUser(raw.assignee) : null,
|
|
327
|
+
updated: raw.updated_at,
|
|
328
|
+
url: raw.html_url
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Accept "#42", "42", or "owner/repo#42" and return the numeric id.
|
|
334
|
+
* Cross-repo refs are allowed in input but the adapter is bound to one
|
|
335
|
+
* repo — we error if the owner/repo doesn't match.
|
|
336
|
+
*/
|
|
337
|
+
function parseIssueNumber(key, owner, repoName) {
|
|
338
|
+
const trimmed = key.trim();
|
|
339
|
+
const crossMatch = trimmed.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
340
|
+
if (crossMatch) {
|
|
341
|
+
if (crossMatch[1] !== owner || crossMatch[2] !== repoName) throw new Error(`Issue key "${key}" references ${crossMatch[1]}/${crossMatch[2]} but this adapter is bound to ${owner}/${repoName}.`);
|
|
342
|
+
return parseInt(crossMatch[3], 10);
|
|
343
|
+
}
|
|
344
|
+
const num = trimmed.replace(/^#/, "");
|
|
345
|
+
if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
|
|
346
|
+
return parseInt(num, 10);
|
|
347
|
+
}
|
|
348
|
+
function mapUser(raw) {
|
|
349
|
+
return {
|
|
350
|
+
id: raw.login,
|
|
351
|
+
displayName: raw.name ?? raw.login,
|
|
352
|
+
...raw.email ? { emailAddress: raw.email } : {}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/sodium.ts
|
|
357
|
+
/**
|
|
358
|
+
* libsodium sealed-box encryption for GitHub Actions secrets.
|
|
359
|
+
*
|
|
360
|
+
* GitHub stores Actions / Codespaces / Dependabot secrets encrypted
|
|
361
|
+
* with a curve25519 public key (returned by the `/actions/secrets/public-key`
|
|
362
|
+
* endpoint). Callers fetch that key, encrypt the plaintext value with
|
|
363
|
+
* `crypto_box_seal`, then PUT the base64-encoded ciphertext + `key_id`.
|
|
364
|
+
*
|
|
365
|
+
* Reference: https://docs.github.com/en/rest/actions/secrets#create-or-update-a-repository-secret
|
|
366
|
+
*
|
|
367
|
+
* **Why createRequire?** `libsodium-wrappers@0.7.x` ships a broken
|
|
368
|
+
* ESM bundle — the `dist/modules-esm/libsodium-wrappers.mjs` entry
|
|
369
|
+
* imports a sibling `libsodium.mjs` that isn't included in the npm
|
|
370
|
+
* tarball. Native `import` against the package therefore fails.
|
|
371
|
+
* `createRequire` routes through Node's CJS resolver, which honors
|
|
372
|
+
* the `"require"` condition in the package's exports map and loads
|
|
373
|
+
* the self-contained CJS bundle instead. Same API, working module.
|
|
374
|
+
*/
|
|
375
|
+
const sodium = createRequire(import.meta.url)("libsodium-wrappers");
|
|
376
|
+
/**
|
|
377
|
+
* Encrypt `value` for the given GitHub-provided base64 public key.
|
|
378
|
+
* Returns the base64 ciphertext to send as `encrypted_value`.
|
|
379
|
+
*/
|
|
380
|
+
async function encryptSecret(publicKeyBase64, value) {
|
|
381
|
+
await sodium.ready;
|
|
382
|
+
const publicKey = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
|
|
383
|
+
const plaintext = sodium.from_string(value);
|
|
384
|
+
const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
|
|
385
|
+
return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/capabilities/secrets.ts
|
|
389
|
+
var GitHubSecrets = class {
|
|
390
|
+
rest;
|
|
391
|
+
key = "secrets";
|
|
392
|
+
providerName = "github";
|
|
393
|
+
repoBase;
|
|
394
|
+
constructor(rest, opts) {
|
|
395
|
+
this.rest = rest;
|
|
396
|
+
const { owner, name } = parseRepo(opts.repo);
|
|
397
|
+
this.repoBase = `/repos/${owner}/${name}`;
|
|
398
|
+
}
|
|
399
|
+
async listSecrets(scope) {
|
|
400
|
+
const base = this.scopeBase(scope);
|
|
401
|
+
return (await this.rest.request(`${base}/secrets`)).secrets.map((s) => s.name);
|
|
402
|
+
}
|
|
403
|
+
async setSecret(scope, name, value) {
|
|
404
|
+
const base = this.scopeBase(scope);
|
|
405
|
+
const pk = await this.rest.request(`${base}/secrets/public-key`);
|
|
406
|
+
const body = {
|
|
407
|
+
encrypted_value: await encryptSecret(pk.key, value),
|
|
408
|
+
key_id: pk.key_id
|
|
409
|
+
};
|
|
410
|
+
if (scope.kind === "organization") body.visibility = "all";
|
|
411
|
+
await this.rest.request(`${base}/secrets/${name}`, {
|
|
412
|
+
method: "PUT",
|
|
413
|
+
body,
|
|
414
|
+
expectNoContent: true
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
async deleteSecret(scope, name) {
|
|
418
|
+
const base = this.scopeBase(scope);
|
|
419
|
+
await this.rest.request(`${base}/secrets/${name}`, {
|
|
420
|
+
method: "DELETE",
|
|
421
|
+
expectNoContent: true
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/** Endpoint root for the given scope. */
|
|
425
|
+
scopeBase(scope) {
|
|
426
|
+
switch (scope.kind) {
|
|
427
|
+
case "repo": return `${this.repoBase}/actions`;
|
|
428
|
+
case "environment": return `${this.repoBase}/environments/${scope.name}`;
|
|
429
|
+
case "organization": return `/orgs/${scope.name}/actions`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/capabilities/source.ts
|
|
435
|
+
/**
|
|
436
|
+
* `source` capability for GitHub.
|
|
437
|
+
*
|
|
438
|
+
* Ported from rando-id/rando.id `packages/cli/src/adapters/gh-rest.ts`
|
|
439
|
+
* with two changes for v2:
|
|
440
|
+
*
|
|
441
|
+
* - Repo coords are bound at construction time (not passed per call)
|
|
442
|
+
* - Workflow files are local-fs operations (not GH API) — they live
|
|
443
|
+
* in the consumer's `.github/workflows/` directory
|
|
444
|
+
*/
|
|
445
|
+
var GitHubSource = class {
|
|
446
|
+
rest;
|
|
447
|
+
key = "source";
|
|
448
|
+
providerName = "github";
|
|
449
|
+
owner;
|
|
450
|
+
name;
|
|
451
|
+
repoPath;
|
|
452
|
+
workflowDir;
|
|
453
|
+
constructor(rest, opts) {
|
|
454
|
+
this.rest = rest;
|
|
455
|
+
const { owner, name } = parseRepo(opts.repo);
|
|
456
|
+
this.owner = owner;
|
|
457
|
+
this.name = name;
|
|
458
|
+
this.repoPath = `/repos/${owner}/${name}`;
|
|
459
|
+
this.workflowDir = join(opts.repoRoot, ".github", "workflows");
|
|
460
|
+
}
|
|
461
|
+
async whoami() {
|
|
462
|
+
return { login: (await this.rest.request("/user")).login };
|
|
463
|
+
}
|
|
464
|
+
async getRepo() {
|
|
465
|
+
const data = await this.rest.request(this.repoPath);
|
|
466
|
+
return {
|
|
467
|
+
owner: this.owner,
|
|
468
|
+
name: this.name,
|
|
469
|
+
defaultBranch: data.default_branch
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
async listRulesets() {
|
|
473
|
+
return this.rest.request(`${this.repoPath}/rulesets`);
|
|
474
|
+
}
|
|
475
|
+
async createRuleset(payload) {
|
|
476
|
+
return this.rest.request(`${this.repoPath}/rulesets`, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
body: payload
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
async updateRuleset(id, payload) {
|
|
482
|
+
return this.rest.request(`${this.repoPath}/rulesets/${id}`, {
|
|
483
|
+
method: "PUT",
|
|
484
|
+
body: payload
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async updateRepoSettings(settings) {
|
|
488
|
+
await this.rest.request(this.repoPath, {
|
|
489
|
+
method: "PATCH",
|
|
490
|
+
body: settings
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
async enableVulnerabilityAlerts() {
|
|
494
|
+
await this.rest.request(`${this.repoPath}/vulnerability-alerts`, {
|
|
495
|
+
method: "PUT",
|
|
496
|
+
expectNoContent: true
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
async enableAutomatedSecurityFixes() {
|
|
500
|
+
await this.rest.request(`${this.repoPath}/automated-security-fixes`, {
|
|
501
|
+
method: "PUT",
|
|
502
|
+
expectNoContent: true
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
async enableSecretScanning() {
|
|
506
|
+
await this.rest.request(this.repoPath, {
|
|
507
|
+
method: "PATCH",
|
|
508
|
+
body: { security_and_analysis: {
|
|
509
|
+
secret_scanning: { status: "enabled" },
|
|
510
|
+
secret_scanning_push_protection: { status: "enabled" }
|
|
511
|
+
} }
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
async enablePrivateVulnerabilityReporting() {
|
|
515
|
+
await this.rest.request(`${this.repoPath}/private-vulnerability-reporting`, {
|
|
516
|
+
method: "PUT",
|
|
517
|
+
expectNoContent: true
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
async listWorkflowFiles() {
|
|
521
|
+
try {
|
|
522
|
+
return (await readdir(this.workflowDir)).filter((e) => e.endsWith(".yml") || e.endsWith(".yaml")).sort();
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (isENOENT(err)) return [];
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async readWorkflowFile(name) {
|
|
529
|
+
try {
|
|
530
|
+
return await readFile(join(this.workflowDir, name), "utf8");
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (isENOENT(err)) return null;
|
|
533
|
+
throw err;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async writeWorkflowFile(name, contents) {
|
|
537
|
+
await ensureDir(this.workflowDir);
|
|
538
|
+
await writeFile(join(this.workflowDir, name), contents, "utf8");
|
|
539
|
+
}
|
|
540
|
+
async removeWorkflowFile(name) {
|
|
541
|
+
try {
|
|
542
|
+
await rm(join(this.workflowDir, name));
|
|
543
|
+
} catch (err) {
|
|
544
|
+
if (isENOENT(err)) return;
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
function isENOENT(err) {
|
|
550
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
551
|
+
}
|
|
552
|
+
async function ensureDir(path) {
|
|
553
|
+
try {
|
|
554
|
+
if (!(await stat(path)).isDirectory()) throw new Error(`${path} exists but is not a directory`);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
if (isENOENT(err)) {
|
|
557
|
+
await mkdir(path, { recursive: true });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
throw err;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
//#endregion
|
|
564
|
+
//#region src/rest.ts
|
|
565
|
+
/**
|
|
566
|
+
* Thin REST wrapper around api.github.com.
|
|
567
|
+
*
|
|
568
|
+
* Adapted from rando-id/rando.id `packages/cli/src/adapters/gh-rest.ts`
|
|
569
|
+
* `request<T>` method into a standalone class so each capability can
|
|
570
|
+
* hold a reference and share the same auth/error handling.
|
|
571
|
+
*
|
|
572
|
+
* Errors throw `ProviderApiError` from `@theholocron/cli`, which the
|
|
573
|
+
* orchestrator catches to soft-skip rather than abort the whole pipeline.
|
|
574
|
+
*/
|
|
575
|
+
var GitHubRestClient = class {
|
|
576
|
+
token;
|
|
577
|
+
fetchImpl;
|
|
578
|
+
baseUrl;
|
|
579
|
+
constructor(opts) {
|
|
580
|
+
this.token = opts.token;
|
|
581
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
582
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
|
|
583
|
+
}
|
|
584
|
+
async request(path, opts = {}) {
|
|
585
|
+
const url = `${this.baseUrl}${path}`;
|
|
586
|
+
const headers = {
|
|
587
|
+
accept: "application/vnd.github+json",
|
|
588
|
+
authorization: `Bearer ${this.token}`,
|
|
589
|
+
"x-github-api-version": "2022-11-28"
|
|
590
|
+
};
|
|
591
|
+
const init = {
|
|
592
|
+
method: opts.method ?? "GET",
|
|
593
|
+
headers
|
|
594
|
+
};
|
|
595
|
+
if (opts.body !== void 0) {
|
|
596
|
+
headers["content-type"] = "application/json";
|
|
597
|
+
init.body = JSON.stringify(opts.body);
|
|
598
|
+
}
|
|
599
|
+
const res = await this.fetchImpl(url, init);
|
|
600
|
+
if (!res.ok) {
|
|
601
|
+
const body = await res.text().catch(() => "");
|
|
602
|
+
throw new ProviderApiError(`GitHub ${init.method} ${path} → ${res.status}`, res.status, body);
|
|
603
|
+
}
|
|
604
|
+
if (opts.expectNoContent || res.status === 204) return;
|
|
605
|
+
const text = await res.text();
|
|
606
|
+
if (!text) return void 0;
|
|
607
|
+
return JSON.parse(text);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/index.ts
|
|
612
|
+
function createContext(options) {
|
|
613
|
+
return {
|
|
614
|
+
options,
|
|
615
|
+
rest: new GitHubRestClient({
|
|
616
|
+
token: resolveToken(options),
|
|
617
|
+
baseUrl: options.baseUrl,
|
|
618
|
+
fetch: options.fetch
|
|
619
|
+
}),
|
|
620
|
+
repo: options.repo,
|
|
621
|
+
repoRoot: options.repoRoot ?? process.cwd()
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function source(ctx) {
|
|
625
|
+
return new GitHubSource(ctx.rest, {
|
|
626
|
+
repo: ctx.repo,
|
|
627
|
+
repoRoot: ctx.repoRoot
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
function secrets(ctx) {
|
|
631
|
+
return new GitHubSecrets(ctx.rest, { repo: ctx.repo });
|
|
632
|
+
}
|
|
633
|
+
function environments(ctx) {
|
|
634
|
+
return new GitHubEnvironments(ctx.rest, { repo: ctx.repo });
|
|
635
|
+
}
|
|
636
|
+
function ci(ctx) {
|
|
637
|
+
return new GitHubCi(ctx.rest, { repo: ctx.repo });
|
|
638
|
+
}
|
|
639
|
+
function issues(ctx) {
|
|
640
|
+
if (!ctx.options.labels) throw new Error("issues capability requires `labels` in plugin options: { inProgress, inReview }");
|
|
641
|
+
return new GitHubIssues(ctx.rest, {
|
|
642
|
+
repo: ctx.repo,
|
|
643
|
+
labels: ctx.options.labels
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function createPlugin(options) {
|
|
647
|
+
const ctx = createContext(options);
|
|
648
|
+
return {
|
|
649
|
+
name: "@theholocron/holocron-plugin-github",
|
|
650
|
+
capabilities: {
|
|
651
|
+
source: () => source(ctx),
|
|
652
|
+
ci: () => ci(ctx),
|
|
653
|
+
secrets: () => secrets(ctx),
|
|
654
|
+
environments: () => environments(ctx),
|
|
655
|
+
issues: () => issues(ctx)
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
//#endregion
|
|
660
|
+
export { AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubRestClient, GitHubSecrets, GitHubSource, ci, createContext, createPlugin, encryptSecret, environments, issues, resolveToken, secrets, source };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theholocron/holocron-plugin-github",
|
|
3
|
+
"version": "2.0.0-alpha.0",
|
|
4
|
+
"description": "Holocron plugin for GitHub. Implements source, ci, secrets, environments, and issues capabilities against the GitHub REST API.",
|
|
5
|
+
"homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-github#readme",
|
|
6
|
+
"bugs": "https://github.com/theholocron/holocron/issues",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/theholocron/holocron.git",
|
|
10
|
+
"directory": "packages/holocron-plugin-github"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Newton Koumantzelis",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.mjs",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.mts",
|
|
19
|
+
"import": "./dist/index.mjs",
|
|
20
|
+
"default": "./dist/index.mjs"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@theholocron/cli": "2.0.0-alpha.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"libsodium-wrappers": "^0.7.15"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@theholocron/eslint-config": "^4.1.0",
|
|
31
|
+
"@theholocron/tsconfig": "^4.1.0",
|
|
32
|
+
"@tsconfig/node-lts": "^24.0.0",
|
|
33
|
+
"@types/libsodium-wrappers": "^0.7.14",
|
|
34
|
+
"@vitest/coverage-v8": "^3.2.6",
|
|
35
|
+
"eslint": "^9.36.0",
|
|
36
|
+
"globals": "^16.5.0",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vitest": "^3.2.6",
|
|
39
|
+
"tsdown": "^0.22.3",
|
|
40
|
+
"@theholocron/cli": "2.0.0-alpha.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsdown",
|
|
51
|
+
"lint": "eslint .",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"test:coverage": "vitest run --coverage"
|
|
56
|
+
},
|
|
57
|
+
"types": "./dist/index.d.mts"
|
|
58
|
+
}
|