@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 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`.
@@ -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
+ }