@intentius/chant-lexicon-forgejo 0.4.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/README.md +116 -0
- package/package.json +46 -0
- package/src/actions.test.ts +82 -0
- package/src/actions.ts +112 -0
- package/src/dialect.test.ts +104 -0
- package/src/dialect.ts +189 -0
- package/src/index.ts +50 -0
- package/src/lint/post-synth/wfj.test.ts +80 -0
- package/src/lint/post-synth/wfj010.ts +38 -0
- package/src/lint/post-synth/wfj011.ts +41 -0
- package/src/mcp/context-tools.test.ts +99 -0
- package/src/mcp/context-tools.ts +263 -0
- package/src/migrate/from-github/index.ts +103 -0
- package/src/migrate/from-github/security.test.ts +120 -0
- package/src/migrate/from-github/security.ts +195 -0
- package/src/migrate.mcp.test.ts +75 -0
- package/src/migrate.test.ts +71 -0
- package/src/plugin.ts +173 -0
- package/src/serializer.test.ts +124 -0
- package/src/serializer.ts +62 -0
- package/src/skills/chant-forgejo.md +68 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Forgejo Actions lexicon — a thin dialect of the github lexicon.
|
|
2
|
+
//
|
|
3
|
+
// Serializer + plugin override only the dialect; everything a user writes
|
|
4
|
+
// (entities, expression helpers, context variables, composites) is reused
|
|
5
|
+
// directly from the github lexicon and re-exported here so a forgejo project
|
|
6
|
+
// imports solely from "@intentius/chant-lexicon-forgejo".
|
|
7
|
+
|
|
8
|
+
// Serializer
|
|
9
|
+
export { forgejoSerializer } from "./serializer";
|
|
10
|
+
|
|
11
|
+
// Plugin
|
|
12
|
+
export { forgejoPlugin } from "./plugin";
|
|
13
|
+
|
|
14
|
+
// Dialect (exposed for tooling/tests)
|
|
15
|
+
export {
|
|
16
|
+
applyForgejoDialect,
|
|
17
|
+
transformWorkflowObject,
|
|
18
|
+
DEFAULT_RUNNER_LABELS,
|
|
19
|
+
type ForgejoDialectOptions,
|
|
20
|
+
type ForgejoDialectResult,
|
|
21
|
+
type TransformObjectResult,
|
|
22
|
+
} from "./dialect";
|
|
23
|
+
|
|
24
|
+
// github → forgejo migration
|
|
25
|
+
export {
|
|
26
|
+
transform,
|
|
27
|
+
detectGitHubWorkflow,
|
|
28
|
+
type MigrateOptions,
|
|
29
|
+
type MigrationResult,
|
|
30
|
+
} from "./migrate/from-github";
|
|
31
|
+
export {
|
|
32
|
+
analyzeForgejoSecurity,
|
|
33
|
+
renderSecurityPosture,
|
|
34
|
+
type SecurityFate,
|
|
35
|
+
type SecurityRecord,
|
|
36
|
+
} from "./migrate/from-github/security";
|
|
37
|
+
|
|
38
|
+
// Action-reference resolver (exposed for tooling/tests)
|
|
39
|
+
export {
|
|
40
|
+
resolveActionRef,
|
|
41
|
+
DEFAULT_ACTIONS_ROOT,
|
|
42
|
+
KNOWN_ACTIONS,
|
|
43
|
+
type ActionTarget,
|
|
44
|
+
type ActionResolveOptions,
|
|
45
|
+
type ActionResolveResult,
|
|
46
|
+
} from "./actions";
|
|
47
|
+
|
|
48
|
+
// Reuse the entire github authoring surface: generated entities, expression
|
|
49
|
+
// system, context variables, and composites.
|
|
50
|
+
export * from "@intentius/chant-lexicon-github";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { wfj010 } from "./wfj010";
|
|
4
|
+
import { wfj011 } from "./wfj011";
|
|
5
|
+
|
|
6
|
+
function makeCtx(yaml: string): PostSynthContext {
|
|
7
|
+
return {
|
|
8
|
+
outputs: new Map([["github", yaml]]),
|
|
9
|
+
entities: new Map(),
|
|
10
|
+
buildResult: {
|
|
11
|
+
outputs: new Map([["github", yaml]]),
|
|
12
|
+
entities: new Map(),
|
|
13
|
+
warnings: [],
|
|
14
|
+
errors: [],
|
|
15
|
+
sourceFileCount: 1,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("WFJ010: unresolved action reference", () => {
|
|
21
|
+
test("flags a bare owner/repo ref", () => {
|
|
22
|
+
const yaml = `name: CI
|
|
23
|
+
on:
|
|
24
|
+
push:
|
|
25
|
+
jobs:
|
|
26
|
+
build:
|
|
27
|
+
runs-on: docker
|
|
28
|
+
steps:
|
|
29
|
+
- uses: some-org/custom-action@v1
|
|
30
|
+
`;
|
|
31
|
+
const diags = wfj010.check(makeCtx(yaml));
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].checkId).toBe("WFJ010");
|
|
34
|
+
expect(diags[0].message).toContain("some-org/custom-action@v1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not flag a resolved full-URL ref", () => {
|
|
38
|
+
const yaml = `name: CI
|
|
39
|
+
on:
|
|
40
|
+
push:
|
|
41
|
+
jobs:
|
|
42
|
+
build:
|
|
43
|
+
runs-on: docker
|
|
44
|
+
steps:
|
|
45
|
+
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
46
|
+
`;
|
|
47
|
+
expect(wfj010.check(makeCtx(yaml))).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("WFJ011: GitHub-hosted runner label", () => {
|
|
52
|
+
test("flags a macos-latest label", () => {
|
|
53
|
+
const yaml = `name: CI
|
|
54
|
+
on:
|
|
55
|
+
push:
|
|
56
|
+
jobs:
|
|
57
|
+
build:
|
|
58
|
+
runs-on: macos-latest
|
|
59
|
+
steps:
|
|
60
|
+
- run: echo hi
|
|
61
|
+
`;
|
|
62
|
+
const diags = wfj011.check(makeCtx(yaml));
|
|
63
|
+
expect(diags).toHaveLength(1);
|
|
64
|
+
expect(diags[0].checkId).toBe("WFJ011");
|
|
65
|
+
expect(diags[0].message).toContain("macos-latest");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("does not flag a mapped Forgejo label", () => {
|
|
69
|
+
const yaml = `name: CI
|
|
70
|
+
on:
|
|
71
|
+
push:
|
|
72
|
+
jobs:
|
|
73
|
+
build:
|
|
74
|
+
runs-on: docker
|
|
75
|
+
steps:
|
|
76
|
+
- run: echo hi
|
|
77
|
+
`;
|
|
78
|
+
expect(wfj011.check(makeCtx(yaml))).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WFJ010: Unresolved action reference.
|
|
3
|
+
*
|
|
4
|
+
* After the forgejo dialect runs, every `uses:` should be a Forgejo-resolvable
|
|
5
|
+
* form (a full URL, a local `./` action, or `docker://`). A bare `owner/repo@ref`
|
|
6
|
+
* that survives into the output won't resolve — Forgejo has no GitHub
|
|
7
|
+
* Marketplace. Flags it so it's caught in `chant lint` / `forgejo:checks`, not
|
|
8
|
+
* only as a build-time warning.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import { extractActionRefs } from "@intentius/chant-lexicon-github/lint/post-synth/yaml-helpers";
|
|
14
|
+
import { resolveActionRef } from "../../actions";
|
|
15
|
+
|
|
16
|
+
export const wfj010: PostSynthCheck = {
|
|
17
|
+
id: "WFJ010",
|
|
18
|
+
description: "Unresolved action reference (won't resolve on Forgejo)",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
for (const [, output] of ctx.outputs) {
|
|
23
|
+
const yaml = getPrimaryOutput(output);
|
|
24
|
+
for (const ref of extractActionRefs(yaml)) {
|
|
25
|
+
if (resolveActionRef(ref.ref).warning) {
|
|
26
|
+
diagnostics.push({
|
|
27
|
+
checkId: "WFJ010",
|
|
28
|
+
severity: "warning",
|
|
29
|
+
message: `Job "${ref.job}" references '${ref.ref}', which has no Forgejo-resolvable form. Use a full repository URL or map it under forgejo.actionsRoot.`,
|
|
30
|
+
entity: ref.job,
|
|
31
|
+
lexicon: "forgejo",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return diagnostics;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WFJ011: GitHub-hosted runner label.
|
|
3
|
+
*
|
|
4
|
+
* `runs-on` labels like `macos-latest` / `windows-latest` name GitHub-hosted
|
|
5
|
+
* runners that don't exist on Forgejo, and `ubuntu-*` labels that the dialect
|
|
6
|
+
* didn't map (e.g. when mapping is overridden away) won't be advertised by a
|
|
7
|
+
* default Forgejo runner. Flags any such label that survives into the output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
11
|
+
import { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
import { extractRunsOnByJob } from "@intentius/chant-lexicon-github/lint/post-synth/yaml-helpers";
|
|
13
|
+
|
|
14
|
+
/** Labels that name a GitHub-hosted runner image (no fixed Forgejo equivalent). */
|
|
15
|
+
const GITHUB_HOSTED = /^(ubuntu|macos|windows)-/;
|
|
16
|
+
|
|
17
|
+
export const wfj011: PostSynthCheck = {
|
|
18
|
+
id: "WFJ011",
|
|
19
|
+
description: "GitHub-hosted runner label with no Forgejo equivalent",
|
|
20
|
+
|
|
21
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
22
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
23
|
+
for (const [, output] of ctx.outputs) {
|
|
24
|
+
const yaml = getPrimaryOutput(output);
|
|
25
|
+
for (const [job, labels] of extractRunsOnByJob(yaml)) {
|
|
26
|
+
for (const label of labels) {
|
|
27
|
+
if (GITHUB_HOSTED.test(label)) {
|
|
28
|
+
diagnostics.push({
|
|
29
|
+
checkId: "WFJ011",
|
|
30
|
+
severity: "warning",
|
|
31
|
+
message: `Job "${job}" runs on '${label}', a GitHub-hosted runner label with no Forgejo equivalent. Map it via forgejo.runnerLabels or target a label your Forgejo runners advertise.`,
|
|
32
|
+
entity: job,
|
|
33
|
+
lexicon: "forgejo",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return diagnostics;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the forgejo read-only context tools. Each builds a small
|
|
3
|
+
* temp project from source and exercises the tool handler directly.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { forgejoContextTools } from "./context-tools";
|
|
11
|
+
|
|
12
|
+
const SOURCE = `import { Workflow, Job, Step, Checkout } from "@intentius/chant-lexicon-forgejo";
|
|
13
|
+
|
|
14
|
+
export const workflow = new Workflow({
|
|
15
|
+
name: "CI",
|
|
16
|
+
on: { push: { branches: ["main"] } },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const build = new Job({
|
|
20
|
+
"runs-on": "ubuntu-latest",
|
|
21
|
+
steps: [
|
|
22
|
+
Checkout({}).step,
|
|
23
|
+
new Step({ name: "Custom", uses: "some-org/custom-action@v1" }),
|
|
24
|
+
new Step({ name: "Test", run: "npm test" }),
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const deploy = new Job({
|
|
29
|
+
"runs-on": "ubuntu-latest",
|
|
30
|
+
needs: ["build"],
|
|
31
|
+
steps: [new Step({ name: "Deploy", run: "./deploy.sh" })],
|
|
32
|
+
});
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
let dir: string;
|
|
36
|
+
const tools = forgejoContextTools();
|
|
37
|
+
const tool = (name: string) => tools.find((t) => t.name === name)!;
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
dir = mkdtempSync(join(tmpdir(), "fj-ctx-"));
|
|
41
|
+
mkdirSync(join(dir, "src"));
|
|
42
|
+
writeFileSync(join(dir, "src", "pipeline.ts"), SOURCE);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(() => {
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("forgejo context tools", () => {
|
|
50
|
+
test("all expected tools are registered", () => {
|
|
51
|
+
expect(tools.map((t) => t.name)).toEqual([
|
|
52
|
+
"forgejo:checks",
|
|
53
|
+
"forgejo:workflow",
|
|
54
|
+
"forgejo:references",
|
|
55
|
+
"forgejo:affected",
|
|
56
|
+
"forgejo:workflow-yaml",
|
|
57
|
+
"forgejo:source",
|
|
58
|
+
"forgejo:owns",
|
|
59
|
+
"forgejo:compare",
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("forgejo:workflow returns triggers and jobs", async () => {
|
|
64
|
+
const result = (await tool("forgejo:workflow").handler({ path: join(dir, "src") })) as {
|
|
65
|
+
workflow: string;
|
|
66
|
+
triggers: string[];
|
|
67
|
+
jobs: Array<{ name: string; runsAfter: string[] }>;
|
|
68
|
+
};
|
|
69
|
+
expect(result.workflow).toBe("CI");
|
|
70
|
+
expect(result.triggers).toContain("push");
|
|
71
|
+
expect(result.jobs.map((j) => j.name).sort()).toEqual(["build", "deploy"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("forgejo:references reports the rewritten + unmapped refs", async () => {
|
|
75
|
+
const refs = (await tool("forgejo:references").handler({ path: join(dir, "src") })) as Array<{ source: string }>;
|
|
76
|
+
const sources = refs.map((r) => r.source);
|
|
77
|
+
expect(sources).toContain("https://code.forgejo.org/actions/checkout@v4");
|
|
78
|
+
expect(sources).toContain("some-org/custom-action@v1");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("forgejo:checks surfaces the unresolved ref (WFJ010)", async () => {
|
|
82
|
+
const result = (await tool("forgejo:checks").handler({ path: join(dir, "src") })) as {
|
|
83
|
+
findings: Array<{ id: string }>;
|
|
84
|
+
};
|
|
85
|
+
expect(result.findings.some((f) => f.id === "WFJ010")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("forgejo:affected traces the needs chain", async () => {
|
|
89
|
+
const result = (await tool("forgejo:affected").handler({ path: join(dir, "src"), job: "build" })) as {
|
|
90
|
+
wouldRerun: string[];
|
|
91
|
+
};
|
|
92
|
+
expect(result.wouldRerun).toContain("deploy");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("forgejo:owns reports a declared job as owned", async () => {
|
|
96
|
+
const result = (await tool("forgejo:owns").handler({ path: join(dir, "src"), job: "build" })) as { owned: boolean };
|
|
97
|
+
expect(result.owned).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only MCP tools for the forgejo lexicon.
|
|
3
|
+
*
|
|
4
|
+
* The forgejo counterpart to the github/gitlab context tools (#327): expose
|
|
5
|
+
* what `chant build` already computes about a Forgejo workflow — its triggers
|
|
6
|
+
* and jobs, what it pulls in from outside, its findings, where each job came
|
|
7
|
+
* from in source — plus `forgejo:compare`, the migration safety view. Because
|
|
8
|
+
* Forgejo emits GitHub-style YAML, the parsing reuses the github lexicon's
|
|
9
|
+
* yaml-helpers.
|
|
10
|
+
*
|
|
11
|
+
* Every tool builds from source and returns data. None touch a live forge,
|
|
12
|
+
* read run history, or write anything.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { build, type BuildResult } from "@intentius/chant/build";
|
|
16
|
+
import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
|
|
17
|
+
import { discoverPostSynthChecks } from "@intentius/chant/lint/discover";
|
|
18
|
+
import { getProvenance } from "@intentius/chant/provenance";
|
|
19
|
+
import type { McpToolContribution } from "@intentius/chant/mcp/types";
|
|
20
|
+
import { dirname, join, relative, isAbsolute, resolve } from "path";
|
|
21
|
+
import { readFile } from "fs/promises";
|
|
22
|
+
import { fileURLToPath } from "url";
|
|
23
|
+
import {
|
|
24
|
+
extractJobs,
|
|
25
|
+
extractActionRefs,
|
|
26
|
+
extractImageRefs,
|
|
27
|
+
extractTriggers,
|
|
28
|
+
extractWorkflowName,
|
|
29
|
+
} from "@intentius/chant-lexicon-github/lint/post-synth/yaml-helpers";
|
|
30
|
+
import { forgejoSerializer } from "../serializer";
|
|
31
|
+
import { transform, detectGitHubWorkflow } from "../migrate/from-github";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the project with the forgejo serializer and return the emitted YAML.
|
|
35
|
+
* The serializer is registered under the "github" lexicon (it serializes the
|
|
36
|
+
* reused github entities), so the output is keyed "github".
|
|
37
|
+
*/
|
|
38
|
+
async function buildForgejo(path: string): Promise<{ yaml: string; result: BuildResult }> {
|
|
39
|
+
const result = await build(path, [forgejoSerializer]);
|
|
40
|
+
const out = result.outputs.get("github");
|
|
41
|
+
return { yaml: out ? getPrimaryOutput(out) : "", result };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Discover the forgejo post-synth checks without depending on the plugin. */
|
|
45
|
+
function forgejoPostSynthChecks() {
|
|
46
|
+
const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "lint", "post-synth");
|
|
47
|
+
return discoverPostSynthChecks(dir, import.meta.url);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Is a `uses:` ref pinned to a full commit SHA (the only immutable form)? */
|
|
51
|
+
export function actionPinned(ref: string): boolean {
|
|
52
|
+
const at = ref.lastIndexOf("@");
|
|
53
|
+
return at !== -1 && /^[0-9a-f]{40}$/.test(ref.slice(at + 1));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The YAML job name a build entity serializes to (camelCase → kebab-case). */
|
|
57
|
+
export function jobNameOf(entityName: string): string {
|
|
58
|
+
return entityName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Find the build entity whose serialized job name matches `job`. */
|
|
62
|
+
function entityForJob(result: BuildResult, job: string): { name: string; entity: object } | undefined {
|
|
63
|
+
for (const [name, entity] of result.entities) {
|
|
64
|
+
if (name === job || jobNameOf(name) === job) return { name, entity };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render a source-file path relative to the project root, when possible. */
|
|
70
|
+
function relSource(root: string, file: string | undefined): string | undefined {
|
|
71
|
+
if (!file) return undefined;
|
|
72
|
+
if (!isAbsolute(file)) return file;
|
|
73
|
+
const rel = relative(root, file);
|
|
74
|
+
return rel.startsWith("..") ? file : rel;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Jobs that would re-run because they depend (transitively) on `job`. */
|
|
78
|
+
export function downstreamJobs(yaml: string, job: string): string[] {
|
|
79
|
+
const jobs = extractJobs(yaml);
|
|
80
|
+
const downstreamOf = new Map<string, string[]>();
|
|
81
|
+
for (const [name, j] of jobs) {
|
|
82
|
+
for (const dep of j.needs ?? []) {
|
|
83
|
+
const arr = downstreamOf.get(dep) ?? [];
|
|
84
|
+
arr.push(name);
|
|
85
|
+
downstreamOf.set(dep, arr);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const out = new Set<string>();
|
|
89
|
+
const queue = [job];
|
|
90
|
+
while (queue.length) {
|
|
91
|
+
const cur = queue.shift()!;
|
|
92
|
+
for (const next of downstreamOf.get(cur) ?? []) {
|
|
93
|
+
if (!out.has(next)) {
|
|
94
|
+
out.add(next);
|
|
95
|
+
queue.push(next);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...out];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const PATH_INPUT = {
|
|
103
|
+
type: "object" as const,
|
|
104
|
+
properties: {
|
|
105
|
+
path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const JOB_INPUT = {
|
|
110
|
+
type: "object" as const,
|
|
111
|
+
properties: {
|
|
112
|
+
path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
|
|
113
|
+
job: { type: "string", description: "Job name (as it appears in the generated YAML)" },
|
|
114
|
+
},
|
|
115
|
+
required: ["job"],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The read-only context tools for the forgejo lexicon. */
|
|
119
|
+
export function forgejoContextTools(): McpToolContribution[] {
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
name: "forgejo:checks",
|
|
123
|
+
description:
|
|
124
|
+
"Build the workflow and return its Forgejo-specific findings (the WFJ checks: unresolved action refs, GitHub-hosted runner labels) as JSON. Read-only.",
|
|
125
|
+
inputSchema: PATH_INPUT,
|
|
126
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
127
|
+
const { yaml, result } = await buildForgejo((params.path as string) ?? ".");
|
|
128
|
+
if (!yaml) return { findings: [], note: "no Forgejo workflow produced from this project" };
|
|
129
|
+
const scoped = { ...result, outputs: new Map([["github", result.outputs.get("github")!]]) };
|
|
130
|
+
const diags = runPostSynthChecks(forgejoPostSynthChecks(), scoped);
|
|
131
|
+
return {
|
|
132
|
+
findings: diags.map((d) => ({ id: d.checkId, severity: d.severity, job: d.entity ?? null, message: d.message })),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "forgejo:workflow",
|
|
138
|
+
description:
|
|
139
|
+
"Build the workflow and return its triggers and jobs as written (name, what each job runs after, step count) — before anything runs. Read-only.",
|
|
140
|
+
inputSchema: PATH_INPUT,
|
|
141
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
142
|
+
const { yaml } = await buildForgejo((params.path as string) ?? ".");
|
|
143
|
+
const jobs = [...extractJobs(yaml).values()].map((j) => ({
|
|
144
|
+
name: j.name,
|
|
145
|
+
runsAfter: j.needs ?? [],
|
|
146
|
+
steps: j.steps?.length ?? 0,
|
|
147
|
+
}));
|
|
148
|
+
return { workflow: extractWorkflowName(yaml) ?? null, triggers: Object.keys(extractTriggers(yaml)), jobs };
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "forgejo:references",
|
|
153
|
+
description:
|
|
154
|
+
"Build the workflow and list everything it pulls in from outside (actions via uses:, container/service images) and whether each is pinned to an immutable commit SHA. Read-only.",
|
|
155
|
+
inputSchema: PATH_INPUT,
|
|
156
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
157
|
+
const { yaml } = await buildForgejo((params.path as string) ?? ".");
|
|
158
|
+
const actions = extractActionRefs(yaml).map((a) => ({
|
|
159
|
+
kind: a.level === "job" ? ("reusable-workflow" as const) : ("action" as const),
|
|
160
|
+
job: a.job,
|
|
161
|
+
source: a.ref,
|
|
162
|
+
pinned: actionPinned(a.ref),
|
|
163
|
+
}));
|
|
164
|
+
const images = extractImageRefs(yaml).map((i) => ({
|
|
165
|
+
kind: "image" as const,
|
|
166
|
+
job: i.job,
|
|
167
|
+
source: i.image,
|
|
168
|
+
pinned: i.image.includes("@sha256:"),
|
|
169
|
+
}));
|
|
170
|
+
return [...actions, ...images];
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "forgejo:affected",
|
|
175
|
+
description:
|
|
176
|
+
"Build the workflow and, given a job name, list the jobs that would re-run because they depend on it (the needs chain). Read-only.",
|
|
177
|
+
inputSchema: JOB_INPUT,
|
|
178
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
179
|
+
const { yaml } = await buildForgejo((params.path as string) ?? ".");
|
|
180
|
+
const job = params.job as string;
|
|
181
|
+
return { job, wouldRerun: downstreamJobs(yaml, job) };
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "forgejo:workflow-yaml",
|
|
186
|
+
description: "Build the project and return the generated Forgejo Actions workflow YAML as a string. Read-only.",
|
|
187
|
+
inputSchema: PATH_INPUT,
|
|
188
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
189
|
+
const { yaml } = await buildForgejo((params.path as string) ?? ".");
|
|
190
|
+
return { yaml };
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "forgejo:source",
|
|
195
|
+
description:
|
|
196
|
+
"Build the project and, given a job name, say where it came from in the TypeScript source — the file that declared it and the composite that expanded it, if any. Entity-level provenance, not a YAML-line source map. Read-only.",
|
|
197
|
+
inputSchema: JOB_INPUT,
|
|
198
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
199
|
+
const root = (params.path as string) ?? ".";
|
|
200
|
+
const { result } = await buildForgejo(root);
|
|
201
|
+
const job = params.job as string;
|
|
202
|
+
const found = entityForJob(result, job);
|
|
203
|
+
if (!found) return { job, found: false, note: "no build entity serializes to that job name" };
|
|
204
|
+
const prov = getProvenance(found.entity);
|
|
205
|
+
return { job, found: true, entity: found.name, from: relSource(root, prov?.sourceFile) ?? null, via: prov?.composite ?? null };
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "forgejo:owns",
|
|
210
|
+
description:
|
|
211
|
+
"Build the project and report whether a job is declared (owned) by chant in this project's source. For workflow config, ownership means \"declared here\" — Forgejo Actions jobs are not taggable cloud resources, so there is no live ownership marker as there is for cloud lexicons. Read-only.",
|
|
212
|
+
inputSchema: JOB_INPUT,
|
|
213
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
214
|
+
const root = (params.path as string) ?? ".";
|
|
215
|
+
const { result } = await buildForgejo(root);
|
|
216
|
+
const job = params.job as string;
|
|
217
|
+
const found = entityForJob(result, job);
|
|
218
|
+
return {
|
|
219
|
+
job,
|
|
220
|
+
owned: Boolean(found),
|
|
221
|
+
basis: "declared-in-source",
|
|
222
|
+
note: "Forgejo Actions jobs are not taggable cloud resources; ownership here means the job is declared by chant in this project. Live ownership markers apply to cloud lexicons (aws/azure/k8s).",
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "forgejo:compare",
|
|
228
|
+
description:
|
|
229
|
+
"Given a GitHub Actions workflow file, migrate it to Forgejo and report which properties survive the move and which weaken or are lost (the migration safety view). Returns a per-property fate (translated/approximated/needs-review/lost) plus summary counts. Read-only — analyzes, never writes.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object" as const,
|
|
232
|
+
properties: {
|
|
233
|
+
file: { type: "string", description: "Path to a .github/workflows/*.yml workflow file to migrate and compare" },
|
|
234
|
+
},
|
|
235
|
+
required: ["file"],
|
|
236
|
+
},
|
|
237
|
+
async handler(params: Record<string, unknown>): Promise<unknown> {
|
|
238
|
+
const file = resolve((params.file as string) ?? "");
|
|
239
|
+
let content: string;
|
|
240
|
+
try {
|
|
241
|
+
content = await readFile(file, "utf8");
|
|
242
|
+
} catch {
|
|
243
|
+
return { file: params.file ?? null, found: false, note: "could not read the workflow file" };
|
|
244
|
+
}
|
|
245
|
+
if (!detectGitHubWorkflow(content)) {
|
|
246
|
+
return { file: params.file ?? null, found: false, note: "file does not look like a GitHub Actions workflow" };
|
|
247
|
+
}
|
|
248
|
+
const migration = await transform(content, { security: true, sourceFile: file });
|
|
249
|
+
const properties = migration.provenance.map((r) => ({
|
|
250
|
+
property: r.security.property,
|
|
251
|
+
fate: r.security.fate,
|
|
252
|
+
severity: r.security.severity,
|
|
253
|
+
sourceKey: r.sourceKey,
|
|
254
|
+
reestablish: r.security.reestablish ?? null,
|
|
255
|
+
note: r.note ?? null,
|
|
256
|
+
}));
|
|
257
|
+
const summary: Record<string, number> = { translated: 0, approximated: 0, "needs-review": 0, lost: 0 };
|
|
258
|
+
for (const p of properties) summary[p.fate] = (summary[p.fate] ?? 0) + 1;
|
|
259
|
+
return { found: true, properties, summary };
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
];
|
|
263
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github → forgejo migration entry point.
|
|
3
|
+
*
|
|
4
|
+
* Thin by design: Forgejo Actions YAML *is* GitHub Actions YAML, so the
|
|
5
|
+
* migration applies the same dialect as a `chant build` (drop ignored keys,
|
|
6
|
+
* resolve `uses:` refs, map runner labels) and emits the result. No stage
|
|
7
|
+
* inference, job→script rewrite, or `!reference` intrinsic like the gitlab
|
|
8
|
+
* migration needs. The differentiated value is the security-fate **compare**
|
|
9
|
+
* (see ./security).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LintDiagnostic } from "@intentius/chant/lint/rule";
|
|
13
|
+
import { parseYAML, emitYAML } from "@intentius/chant/yaml";
|
|
14
|
+
import { transformWorkflowObject } from "../../dialect";
|
|
15
|
+
import {
|
|
16
|
+
analyzeForgejoSecurity,
|
|
17
|
+
provenanceToDiagnostics,
|
|
18
|
+
renderSecurityPosture,
|
|
19
|
+
type SecurityRecord,
|
|
20
|
+
} from "./security";
|
|
21
|
+
|
|
22
|
+
export interface MigrateOptions {
|
|
23
|
+
/** Output format. Defaults to "yaml". */
|
|
24
|
+
emit?: "yaml" | "ts";
|
|
25
|
+
/** Source file path (display only). */
|
|
26
|
+
sourceFile?: string;
|
|
27
|
+
/** Escalate needs-review/lost findings to errors. */
|
|
28
|
+
strict?: boolean;
|
|
29
|
+
/** Accepted for parity with the core MigrationSource contract; forgejo
|
|
30
|
+
* always runs the (cheap) security analysis. */
|
|
31
|
+
security?: boolean;
|
|
32
|
+
/** Accepted for parity; forgejo has no composite rewriter. */
|
|
33
|
+
useComposites?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MigrationResult {
|
|
37
|
+
/** Rendered output (forgejo YAML by default, chant TS when emit: "ts"). */
|
|
38
|
+
output: string;
|
|
39
|
+
/** Per-property security-fate records. */
|
|
40
|
+
provenance: SecurityRecord[];
|
|
41
|
+
/** SARIF-shaped diagnostics derived from the records. */
|
|
42
|
+
diagnostics: LintDiagnostic[];
|
|
43
|
+
/** Markdown "Security posture" section. */
|
|
44
|
+
securityPosture: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Emit a parsed/transformed workflow object back to YAML. */
|
|
48
|
+
function emitForgejoYaml(value: unknown): string {
|
|
49
|
+
const body = emitYAML(value, 0);
|
|
50
|
+
return (body.startsWith("\n") ? body.slice(1) : body) + "\n";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Emit a chant TypeScript pipeline (authored github-style, imported from forgejo). */
|
|
54
|
+
async function emitTs(content: string, opts: MigrateOptions): Promise<string> {
|
|
55
|
+
const { GitHubActionsParser } = await import("@intentius/chant-lexicon-github/import/parser");
|
|
56
|
+
const { GitHubActionsGenerator } = await import("@intentius/chant-lexicon-github/import/generator");
|
|
57
|
+
const ir = new GitHubActionsParser().parse(content);
|
|
58
|
+
const files = new GitHubActionsGenerator().generate(ir);
|
|
59
|
+
const banner =
|
|
60
|
+
`// Migrated from ${opts.sourceFile ?? "(stdin)"} by chant migrate (github → forgejo).\n` +
|
|
61
|
+
`// Authored github-style; the forgejo lexicon applies its dialect on build.\n\n`;
|
|
62
|
+
return (
|
|
63
|
+
banner +
|
|
64
|
+
files
|
|
65
|
+
.map((f) => f.content.replace(/@intentius\/chant-lexicon-github/g, "@intentius/chant-lexicon-forgejo"))
|
|
66
|
+
.join("\n")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Migrate a GitHub Actions workflow into a Forgejo workflow.
|
|
72
|
+
*
|
|
73
|
+
* @param content raw .github/workflows/*.yml content
|
|
74
|
+
* @param opts migration options
|
|
75
|
+
*/
|
|
76
|
+
export async function transform(content: string, opts: MigrateOptions = {}): Promise<MigrationResult> {
|
|
77
|
+
const source = parseYAML(content);
|
|
78
|
+
|
|
79
|
+
let output: string;
|
|
80
|
+
if (opts.emit === "ts") {
|
|
81
|
+
output = await emitTs(content, opts);
|
|
82
|
+
} else {
|
|
83
|
+
const { value } = transformWorkflowObject(source);
|
|
84
|
+
output = emitForgejoYaml(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The compare is computed against the *source* — that's where the dropped
|
|
88
|
+
// keys still exist — so it reports what the move costs.
|
|
89
|
+
const provenance = analyzeForgejoSecurity(source, { sourceFile: opts.sourceFile });
|
|
90
|
+
const diagnostics = provenanceToDiagnostics(provenance, { strict: opts.strict });
|
|
91
|
+
const securityPosture = renderSecurityPosture(provenance);
|
|
92
|
+
|
|
93
|
+
return { output, provenance, diagnostics, securityPosture };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lightweight detector: does this content look like a GitHub Actions workflow?
|
|
98
|
+
* Used by the plugin's `migrationSource("github")`.
|
|
99
|
+
*/
|
|
100
|
+
export function detectGitHubWorkflow(content: string): boolean {
|
|
101
|
+
if (!/^\s*jobs\s*:/m.test(content)) return false;
|
|
102
|
+
return /^\s*on\s*:/m.test(content) || /^\s*runs-on\s*:/m.test(content);
|
|
103
|
+
}
|