Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ on:
branches: [develop]
paths:
- "**/*.md"
- ".github/automation/**"
- ".github/labels.yml"
- ".github/labeler.yml"
- ".github/issue-types.yml"
- ".github/agents/**"
push:
branches: [develop]
paths:
- "**/*.md"
- ".github/automation/**"
- ".github/labels.yml"
- ".github/labeler.yml"
- ".github/issue-types.yml"
- ".github/agents/**"
schedule:
- cron: "0 3 * * 1" # weekly metrics roll-up (Mon 03:00 UTC)
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ jobs:
check:
runs-on: ubuntu-latest
env:
HUSKY: "0"
HUSKY: '0'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
node-version-file: '.nvmrc'
- run: node --version && npm --version
- run: npm ci
- run: npm run lint:js
Expand Down
48 changes: 48 additions & 0 deletions .schemas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
file_type: "index"
title: "Portable Schemas"
description: "Ownership index for portable schemas used by LightSpeed AI assets and plugin metadata."
version: "v0.1.1"
last_updated: "2026-05-26"
maintainer: "LightSpeed Team"
authors: ["Codex"]
license: "GPL-3.0"
tags: ["schemas", "ai-ops", "plugin-restructure"]
domain: "governance"
stability: "active"
---

# Portable Schemas

This folder owns portable schema files for AI assets, plugin metadata, and shared validation contracts that should travel outside the `.github` control plane.

## Ownership

- Owns JSON Schema, YAML schema, and frontmatter schema contracts used by portable agents, instructions, skills, hooks, plugins, and workflows.
- Does not own GitHub-native schemas that only validate this repository's community-health files.
- Keeps schemas small, explicit, and tied to active validation commands.

## Structure

| Path | Purpose |
| --- | --- |
| `.schemas/*.schema.json` | Portable JSON Schema files. |
| `.schemas/*.schema.yaml` | Portable YAML schema files, when JSON is not practical. |
| `.schemas/README.md` | This ownership index. |

## Migration rules

- Move schemas here only when the migration map marks them as portable.
- Leave repo-only validation schemas under `.github/schemas/` until a specific migration issue moves them.
- Do not mix schema syntax fixes with path migration unless the assigned issue explicitly covers both.
- Keep schema references relative to the portable source tree, not hard-coded to `.github`.
Comment on lines 33 to 38
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the repository's general rules, migration maps and notes should be documented in a central /docs/MIGRATION.md file to ensure contributors can follow migration rules mentioned in README files across the repository. Please move or reference these migration rules in /docs/MIGRATION.md.

Suggested change
## Migration rules
- Move schemas here only when the migration map marks them as portable.
- Leave repo-only validation schemas under `.github/schemas/` until a specific migration issue moves them.
- Do not mix schema syntax fixes with path migration unless the assigned issue explicitly covers both.
- Keep schema references relative to the portable source tree, not hard-coded to `.github`.
## Migration rules
Please refer to the central [Migration Guide](../docs/MIGRATION.md) for the authoritative migration maps and rules.
References
  1. Document migration maps and notes in a central /docs/MIGRATION.md file to ensure contributors can follow migration rules mentioned in README files across the repository.


## Governance links

- [Portable AI plugin restructure PRD](../.github/projects/archived/portable-ai-plugin-restructure/portable-ai-plugin-restructure-prd-2026-05-14.md)
- [Documentation format standards](../instructions/documentation-formats.instructions.md)

## References

- [Issue #290 draft](../.github/projects/archived/portable-ai-plugin-restructure/issues/children/batch-01-skeleton-boundary/01-02-document-folder-ownership-indexes.md)
- [Migration decision map](../.github/projects/archived/portable-ai-plugin-restructure/portable-ai-plugin-restructure-migration-map-2026-05-15.csv)
17 changes: 17 additions & 0 deletions scripts/agents/__tests__/project-meta-sync.agent.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Jest suite verifying the baseline behaviour of `project-meta-sync.agent.js`.
* @see ../project-meta-sync.agent.js
*/
const agent = require('../project-meta-sync.agent');

describe('project-meta-sync.agent', () => {
it('exports a callable function', () => {
expect(typeof agent).toBe('function');
});

it('does not execute run() on require (no LS_PROJECT_URL side-effect)', () => {
// If the module-scope guard is absent, requiring the file calls run() immediately,
// which throws "LS_PROJECT_URL not set" and sets process.exitCode = 1.
expect(process.exitCode).not.toBe(1);
});
});
286 changes: 7 additions & 279 deletions scripts/agents/__tests__/reviewer.agent.test.js
Original file line number Diff line number Diff line change
@@ -1,285 +1,13 @@
/**
* Tests for reviewer.agent.js reliability and blocker detection.
* Jest suite verifying the baseline behaviour of `reviewer.agent.js`.
* @see ../reviewer.agent.js
*/
const fs = require("fs");
const path = require("path");

async function loadReviewerModules() {
const core = await import("@actions/core");
const github = await import("@actions/github");
const reviewer = await import("../reviewer.agent.js");
return { core, github, reviewer };
}

function createOctokit(options = {}) {
const {
ciState = "success",
changedFiles = ["src/app.js", "CHANGELOG.md"],
listFilesReject = null,
statusReject = null,
createCommentReject = null,
largeDeletion = false,
} = options;

const filePayload = changedFiles.map((filename) => ({
filename,
additions: 1,
deletions: largeDeletion ? 600 : 1,
}));

const octokit = {
rest: {
repos: {
getCombinedStatusForRef: statusReject
? jest.fn().mockRejectedValue(statusReject)
: jest.fn().mockResolvedValue({ data: { state: ciState } }),
},
pulls: {
listFiles: listFilesReject
? jest.fn().mockRejectedValue(listFilesReject)
: jest.fn().mockResolvedValue({ data: filePayload }),
},
issues: {
listComments: jest.fn().mockResolvedValue({ data: [] }),
createComment: createCommentReject
? jest.fn().mockRejectedValue(createCommentReject)
: jest.fn().mockResolvedValue({ data: { id: 1 } }),
updateComment: jest.fn().mockResolvedValue({ data: { id: 2 } }),
},
},
paginate: jest.fn().mockImplementation((_fn, params = {}) => {
if (params.pull_number) {
if (listFilesReject) {
return Promise.reject(listFilesReject);
}
return Promise.resolve(filePayload);
}
return Promise.resolve([]);
}),
};

return octokit;
}

describe("reviewer.agent run", () => {
let exitSpy;
const context = {
repo: { owner: "lightspeedwp", repo: ".github" },
payload: {
pull_request: {
number: 100,
head: { sha: "abc123" },
},
},
};

beforeEach(() => {
jest.resetModules();
exitSpy = jest.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`);
});
});

afterEach(() => {
jest.restoreAllMocks();
delete process.env.GITHUB_TOKEN;
delete process.env.DRY_RUN;
});

test("fails fast when token is missing", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit();

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "setFailed").mockImplementation(() => {});
delete process.env.GITHUB_TOKEN;
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).rejects.toThrow("process.exit:1");
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Missing GITHUB_TOKEN"),
);
});

test("dry-run mode logs and skips comment creation", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit();

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "info").mockImplementation(() => {});
process.env.GITHUB_TOKEN = "test-token";
process.env.DRY_RUN = "true";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).resolves.toBeUndefined();
expect(core.info).toHaveBeenCalledWith(
expect.stringContaining("DRY-RUN: Would post comment"),
);
expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
});

test("adds CI blocker when status is not success", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({ ciState: "failure" });

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).resolves.toBeUndefined();
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.stringContaining("CI checks not green"),
}),
);
});

test("flags missing changelog when required and src changes are present", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({
changedFiles: ["src/logic.js", "docs/README.md"],
});

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "true";
return "";
});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).resolves.toBeUndefined();
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.stringContaining("CHANGELOG.md missing for code change"),
}),
);
});

test("fails with actionable message when listFiles API call fails", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({
listFilesReject: new Error("API unavailable"),
});

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "setFailed").mockImplementation(() => {});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).rejects.toThrow("process.exit:1");
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Failed to fetch files for PR #100"),
);
});

test("fails with actionable message when posting comment fails", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({
createCommentReject: new Error("permission denied"),
});

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "setFailed").mockImplementation(() => {});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).rejects.toThrow("process.exit:1");
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining("Failed to post comment on PR #100"),
);
});

test("warns and continues when CI status API fails", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({
statusReject: new Error("status endpoint timeout"),
changedFiles: ["README.md"],
});

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "warning").mockImplementation(() => {});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).resolves.toBeUndefined();
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining("Could not fetch CI status for ref"),
);
expect(octokit.rest.issues.createComment).toHaveBeenCalled();
});

test("returns early when no pull request exists in context", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit();

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
jest.spyOn(core, "info").mockImplementation(() => {});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(
reviewer.run({ repo: context.repo, payload: {} }, { dryRun: true }),
).resolves.toBeUndefined();
expect(core.info).toHaveBeenCalledWith("No PR in context; exiting.");
expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
});

test("reports high-risk blockers for security-sensitive and large deletions", async () => {
const { core, github, reviewer } = await loadReviewerModules();
const octokit = createOctokit({
changedFiles: [
".github/workflows/release.yml",
"migrations/add-users.sql",
],
largeDeletion: true,
});

jest.spyOn(core, "getInput").mockImplementation((name) => {
if (name === "github-token") return "";
if (name === "require-changelog") return "false";
return "";
});
process.env.GITHUB_TOKEN = "test-token";
jest.spyOn(github, "getOctokit").mockReturnValue(octokit);

await expect(reviewer.run(context)).resolves.toBeUndefined();
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.stringContaining("Security-sensitive files modified"),
}),
);
expect(octokit.rest.issues.createComment).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.stringContaining("Large deletion detected"),
}),
);
describe("reviewer.agent", () => {
it("agent module file exists", () => {
const agentPath = path.join(__dirname, "../reviewer.agent.js");
expect(fs.existsSync(agentPath)).toBe(true);
});
Comment on lines +8 to 12
});
Comment on lines +8 to 13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Checking only for the existence of the file using fs.existsSync is a very weak test that does not verify if the module can be successfully required or if it has any syntax errors. Please require the module and verify its baseline exports/behavior, similar to project-meta-sync.agent.test.js.

Suggested change
describe("reviewer.agent", () => {
it("agent module file exists", () => {
const agentPath = path.join(__dirname, "../reviewer.agent.js");
expect(fs.existsSync(agentPath)).toBe(true);
});
});
describe("reviewer.agent", () => {
it("exports a callable function", () => {
const agent = require("../reviewer.agent");
expect(typeof agent).toBe("function");
});
});

Loading
Loading