Instructions for refactoring JavaScript code into separate files
This guide explains how to refactor JavaScript code into a separate .cjs file in the gh-aw repository. Follow these steps when extracting shared functionality or creating new JavaScript modules.
The gh-aw project uses CommonJS modules (.cjs files) for JavaScript code that runs in GitHub Actions workflows. These files are:
//go:embed directivesrequire() callsactions/github-script@v8Top-level .cjs scripts (those that are executed directly in workflows) follow a specific pattern:
✅ Correct Pattern - Export main, but don't call it:
async function main() {
// Script logic here
core.info("Running the script");
}
module.exports = { main };
❌ Incorrect Pattern - Don't call main in the file:
async function main() {
// Script logic here
core.info("Running the script");
}
await main(); // ❌ Don't do this!
module.exports = { main };
Why this pattern?
await main() during inline execution in GitHub Actionsmain() with mocksExamples of top-level scripts:
create_issue.cjs - Creates GitHub issuesadd_comment.cjs - Adds comments to issues/PRsadd_labels.cjs - Adds labels to issues/PRsupdate_project.cjs - Updates GitHub ProjectsAll of these files export main but do not call it directly.
Create your new file in /home/runner/work/gh-aw/gh-aw/pkg/workflow/js/ with a descriptive name:
File naming convention:
sanitize_content.cjs, load_agent_output.cjs).cjs extension (CommonJS module)Example file structure:
// @ts-check
/// <reference types="@actions/github-script" />
/**
* Brief description of what this module does
*/
/**
* Function documentation
* @param {string} input - Description of parameter
* @returns {string} Description of return value
*/
function myFunction(input) {
// Implementation
return input;
}
// Export the function(s)
module.exports = {
myFunction,
};
Key points:
// @ts-check for TypeScript checking/// <reference types="@actions/github-script" /> for GitHub Actions typesmodule.exports = { ... }@actions/core or @actions/github - these are available globally in GitHub ActionsCreate a test file with the same base name plus .test.cjs:
Example: pkg/workflow/js/my_module.test.cjs
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock the global objects that GitHub Actions provides
const mockCore = {
debug: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
setFailed: vi.fn(),
setOutput: vi.fn(),
summary: {
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(),
},
};
// Set up global mocks before importing the module
global.core = mockCore;
describe("myFunction", () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
});
it("should handle basic input", async () => {
// Import the module to test
const { myFunction } = await import("./my_module.cjs");
const result = myFunction("test input");
expect(result).toBe("expected output");
});
it("should handle edge cases", async () => {
const { myFunction } = await import("./my_module.cjs");
const result = myFunction("");
expect(result).toBe("");
});
});
Testing guidelines:
core and github globals as neededawait import()) to allow mocking before module loadbeforeEach to ensure test isolationpkg/workflow/js/*.test.cjs filesRun tests:
make test-js
Add an //go:embed directive and variable in the appropriate Go file:
Add to pkg/workflow/js.go:
//go:embed js/my_module.cjs
var myModuleScript string
Then add to the GetJavaScriptSources() function:
func GetJavaScriptSources() map[string]string {
return map[string]string{
"sanitize_content.cjs": sanitizeContentScript,
"sanitize_label_content.cjs": sanitizeLabelContentScript,
"sanitize_workflow_name.cjs": sanitizeWorkflowNameScript,
"load_agent_output.cjs": loadAgentOutputScript,
"staged_preview.cjs": stagedPreviewScript,
"is_truthy.cjs": isTruthyScript,
"my_module.cjs": myModuleScript, // Add this line
}
}
Add to pkg/workflow/scripts.go:
//go:embed js/my_script.cjs
var myScriptSource string
Then create a getter function with bundling:
var (
myScript string
myScriptOnce sync.Once
)
// getMyScript returns the bundled my_script script
// Bundling is performed on first access and cached for subsequent calls
func getMyScript() string {
myScriptOnce.Do(func() {
sources := GetJavaScriptSources()
bundled, err := BundleJavaScriptFromSources(myScriptSource, sources, "")
if err != nil {
scriptsLog.Printf("Bundling failed for my_script, using source as-is: %v", err)
// If bundling fails, use the source as-is
myScript = myScriptSource
} else {
myScript = bundled
}
})
return myScript
}
Important:
js.go are for shared utilities that get bundled into other scriptsscripts.go are for main scripts that use the bundler to inline dependenciessync.Once pattern for lazy bundling in scripts.gorequire() calls at runtimeIf you're creating a shared utility that will be used by other scripts via require(), it's automatically available through the GetJavaScriptSources() map (Step 3).
The bundler will:
require('./my_module.cjs') in any scriptGetJavaScriptSources() maprequire() statementNo additional bundler registration needed - just ensure the file is in the GetJavaScriptSources() map.
To use your new module in other JavaScript files, use CommonJS require():
Example usage in another .cjs file:
// @ts-check
/// <reference types="@actions/github-script" />
const { myFunction } = require("./my_module.cjs");
async function main() {
const result = myFunction("some input");
core.info(`Result: ${result}`);
}
module.exports = { main };
Important: Top-level scripts should export main but NOT call it directly. The bundler injects await main() during inline execution in GitHub Actions.
Require guidelines:
./.cjs extensionMultiple requires example:
const { sanitizeContent } = require("./sanitize_content.cjs");
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { generateStagedPreview } = require("./staged_preview.cjs");
Let's walk through creating a new format_timestamp.cjs utility:
pkg/workflow/js/format_timestamp.cjs// @ts-check
/// <reference types="@actions/github-script" />
/**
* Formats a timestamp to ISO 8601 format
* @param {Date|string|number} timestamp - Timestamp to format
* @returns {string} ISO 8601 formatted timestamp
*/
function formatTimestamp(timestamp) {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toISOString();
}
/**
* Formats a timestamp to a human-readable string
* @param {Date|string|number} timestamp - Timestamp to format
* @returns {string} Human-readable timestamp
*/
function formatTimestampHuman(timestamp) {
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short'
});
}
module.exports = {
formatTimestamp,
formatTimestampHuman,
};
pkg/workflow/js/format_timestamp.test.cjsimport { describe, it, expect } from "vitest";
describe("formatTimestamp", () => {
it("should format Date object to ISO 8601", async () => {
const { formatTimestamp } = await import("./format_timestamp.cjs");
const date = new Date('2024-01-15T12:30:00Z');
const result = formatTimestamp(date);
expect(result).toBe('2024-01-15T12:30:00.000Z');
});
it("should format timestamp number to ISO 8601", async () => {
const { formatTimestamp } = await import("./format_timestamp.cjs");
const timestamp = 1705323000000; // Jan 15, 2024 12:30:00 UTC
const result = formatTimestamp(timestamp);
expect(result).toBe('2024-01-15T12:30:00.000Z');
});
});
describe("formatTimestampHuman", () => {
it("should format Date object to human-readable string", async () => {
const { formatTimestampHuman } = await import("./format_timestamp.cjs");
const date = new Date('2024-01-15T12:30:00Z');
const result = formatTimestampHuman(date);
expect(result).toContain('Jan');
expect(result).toContain('15');
expect(result).toContain('2024');
});
});
pkg/workflow/js.go://go:embed js/format_timestamp.cjs
var formatTimestampScript string
func GetJavaScriptSources() map[string]string {
return map[string]string{
// ... existing entries ...
"format_timestamp.cjs": formatTimestampScript,
}
}
// @ts-check
/// <reference types="@actions/github-script" />
const { formatTimestamp } = require("./format_timestamp.cjs");
async function main() {
const now = new Date();
core.info(`Current time: ${formatTimestamp(now)}`);
}
module.exports = { main };
Note: The script exports main but does not call it. The bundler will inject await main() when the script is executed inline in GitHub Actions.
# Format the code
make fmt-cjs
# Run JavaScript tests
make test-js
# Run Go tests (includes bundler tests)
make test-unit
# Build the binary (embeds JavaScript files)
make build
Before committing your refactored code:
.cjs file created in pkg/workflow/js/.test.cjs filemake test-jspkg/workflow/js.go or pkg/workflow/scripts.goGetJavaScriptSources() mapsync.Oncerequire() statements work correctly in other filesmake fmt-cjsmake lint-cjsmake test-unitmake buildFiles like sanitize_content.cjs, load_agent_output.cjs that provide reusable functions:
js.go with //go:embedGetJavaScriptSources() maprequire() in other scriptsFiles like create_issue.cjs, add_labels.cjs that are top-level scripts:
scripts.go with //go:embed as xxxSource variablesync.Once patternrequire() utilities from GetJavaScriptSources()main function but NOT call it - the bundler injects await main() during executionFiles like parse_claude_log.cjs that parse AI engine logs:
js.go with //go:embedGetLogParserScript() functionCause: File not added to GetJavaScriptSources() map
Solution: Add the file to the map in pkg/workflow/js.go
Cause: Missing global mocks
Solution: Add proper mocks before importing the module:
global.core = mockCore;
global.github = mockGithub;
Cause: File A requires File B which requires File A
Solution: Restructure to break the circular dependency, or combine the modules
Cause: Go build cache not recognizing embedded file changes
Solution:
make clean
make build
pkg/workflow/bundler.gopkg/workflow/js.gopkg/workflow/scripts.gopkg/workflow/js/*.test.cjs