mcpField
J
javascript-refactoring

javascript-refactoring

github.com/github开发5.5k68 次安装今天更新
代码仓库

关于 javascript-refactoring

面向 gh-aw 工作流 JavaScript 重构的开发指南,介绍 CommonJS `.cjs` 模块的组织方式,以及如何在 Go 中嵌入、打包和测试。

主要功能

  1. 01CommonJS 模块约定
  2. 02Go 嵌入与打包
  3. 03工具模块和脚本模式
  4. 04Vitest 测试指南
  5. 05格式化与验证清单

适用场景

  1. 01将工作流 JavaScript 拆分为专注模块
  2. 02添加共享工具模块或顶层工作流脚本
  3. 03测试并验证 `.cjs` 变更

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/github/gh-aw --skill javascript-refactoring

安装后在 Claude Code 中输入/javascript-refactoring确认已加载。

详细介绍

JavaScript 代码重构指南

使用本指南将 JavaScript 重构为 gh-aw 中独立的 .cjs 文件。

概览

gh-aw 在 GitHub Actions 工作流中使用 CommonJS 模块(.cjs)编写 JavaScript。这些文件会:

  • 使用 //go:embed 指令嵌入 Go 二进制文件
  • 使用自定义 JavaScript 打包器打包,该打包器会内联本地 require() 调用
  • 在 GitHub Actions 中通过 actions/github-script@v8 执行
顶层脚本模式

在工作流中直接执行的顶层 .cjs 脚本应遵循以下模式:

✅ 正确模式——导出 main,但不要调用它:

async function main() {
  // Script logic here
  core.info("Running the script");
}

module.exports = { main };

❌ 错误模式——不要在文件中调用 main:

async function main() {
  // Script logic here
  core.info("Running the script");
}

await main(); // ❌ Don't do this!

module.exports = { main };

为什么要采用这种模式?

  • 打包器会在 GitHub Actions 中内联执行时自动注入 await main()
  • 这样脚本既可以导入(用于测试),也可以执行(用于工作流)
  • 这种模式能清晰地区分模块定义和执行
  • 测试可以导入 main 并使用模拟对象调用它,从而更便于测试

顶层脚本示例:

  • create_issue.cjs - 创建 GitHub issue
  • add_comment.cjs - 为 issue/PR 添加评论
  • add_labels.cjs - 为 issue/PR 添加标签
  • update_project.cjs - 更新 GitHub Projects

这些文件都会导出 main,但不会直接调用它。

第 1 步:创建新的 .cjs 文件

在 /home/runner/work/gh-aw/gh-aw/pkg/workflow/js/ 中创建新文件,并使用描述性名称:

文件命名约定:

  • 文件名使用 snake_case(例如,sanitize_content.cjs、load_agent_output.cjs)
  • 使用 .cjs 扩展名(CommonJS 模块)
  • 选择能够清晰描述模块用途的名称

文件结构示例:

// @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,
};

要点:

  • 添加 // @ts-check 以启用 TypeScript 检查
  • 添加 /// <reference types="@actions/github-script" /> 以引用 GitHub Actions 类型
  • 使用 JSDoc 注释编写文档
  • 使用 module.exports = { ... } 导出函数
  • 不要导入 @actions/core 或 @actions/github——这些在 GitHub Actions 中可全局使用

第 2 步:添加测试

创建一个测试文件,其基本名称与源文件相同,并以 .test.cjs 结尾:

示例: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("");
  });
});

测试指南:

  • 使用 vitest 作为测试框架
  • 根据需要模拟 core 和 github 全局对象
  • 使用动态导入(await import()),以便在模块加载前进行模拟
  • 在 beforeEach 中清除模拟,以确保测试隔离
  • 测试成功场景和错误处理
  • 遵循 pkg/workflow/js/*.test.cjs 文件中的现有测试模式

运行测试:

make test-js

第 3 步:在 Go 中添加嵌入变量

在相应的 Go 文件中添加 //go:embed 指令和变量:

对于共享工具函数(由多个脚本使用):

添加到 pkg/workflow/js.go:

//go:embed js/my_module.cjs
var myModuleScript string

然后添加到 GetJavaScriptSources() 函数中:

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
	}
}
对于主脚本(使用打包的顶层脚本):

添加到 pkg/workflow/scripts.go:

//go:embed js/my_script.cjs
var myScriptSource string

然后创建一个带打包功能的 getter 函数:

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
}

重要提示:

  • js.go 中的变量用于打包到其他脚本中的共享工具
  • scripts.go 中的变量用于通过打包器内联依赖项的主脚本
  • 在 scripts.go 中使用 sync.Once 模式进行延迟打包
  • 打包器会在运行时内联所有本地 require() 调用

第 4 步:在打包器中注册(如果要创建共享工具)

如果要创建供其他脚本通过 require() 使用的共享工具,它会通过 GetJavaScriptSources() 映射(第 3 步)自动提供。

打包器会:

  1. 检测任何脚本中的 require('./my_module.cjs')
  2. 在 GetJavaScriptSources() 映射中查找该文件
  3. 内联所需模块的内容
  4. 移除 require() 语句
  5. 如果同一模块被多次引入,则进行去重

无需额外注册打包器——只需确保文件位于 GetJavaScriptSources() 映射中。

步骤 5:在其他 JavaScript 文件中使用本地 require

要在其他 JavaScript 文件中使用新模块,请使用 CommonJS require():

在另一个 .cjs 文件中的用法示例:

// @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 };

重要:顶层脚本应导出 main,但不得直接调用它。打包器会在 GitHub Actions 中内联执行时注入 await main()。

require 指南:

  • 使用以 ./ 开头的相对路径
  • 包含 .cjs 扩展名
  • 使用解构导入特定函数
  • 打包器会在编译时内联所需模块

多个 require 的示例:

const { sanitizeContent } = require("./sanitize_content.cjs");
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { generateStagedPreview } = require("./staged_preview.cjs");

完整示例:创建新的实用工具模块

下面介绍如何创建一个新的 format_timestamp.cjs 实用工具:

1. 创建文件: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,
};
2. 创建测试:pkg/workflow/js/format_timestamp.test.cjs
import { 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');
  });
});
3. 添加到 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,
	}
}
4. 在另一个脚本中使用:
// @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 };

**注意:**该脚本会导出 main,但不会调用它。脚本在 GitHub Actions 中以内联方式执行时,打包器会注入 await main()。

5. 构建并测试:
# 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

验证清单

提交重构后的代码前:

  • 在 pkg/workflow/js/ 中创建新的 .cjs 文件
  • 在对应的 .test.cjs 文件中创建测试
  • 使用 make test-js 运行测试并确保通过
  • 在 pkg/workflow/js.go 或 pkg/workflow/scripts.go 中添加嵌入变量
  • 如果是实用工具:添加到 GetJavaScriptSources() 映射中
  • 如果是主脚本:使用 sync.Once 创建打包 getter 函数
  • 在其他文件中的本地 require() 语句正常工作
  • 使用 make fmt-cjs 格式化代码
  • 使用 make lint-cjs 检查代码
  • 使用 make test-unit 确保所有 Go 测试通过
  • 使用 make build 确保构建成功

常见模式

模式 1:共享实用工具函数

像 sanitize_content.cjs、load_agent_output.cjs 这样的文件会提供可重用的函数:

  • 使用 //go:embed 添加到 js.go
  • 添加到 GetJavaScriptSources() 映射中
  • 在其他脚本中通过 require() 使用
模式 2:主工作流脚本

像 create_issue.cjs、add_labels.cjs 这样的文件属于顶层脚本:

  • 使用 //go:embed 作为 xxxSource 变量添加到 scripts.go
  • 使用 sync.Once 模式创建打包 getter 函数
  • 这些脚本可以通过 require() 使用 GetJavaScriptSources() 中的实用工具
  • 必须导出 main 函数,但不得调用它——打包器会在执行时注入 await main()
模式 3:日志解析器

像 parse_claude_log.cjs 这样的文件用于解析 AI 引擎日志:

  • 使用 //go:embed 添加到 js.go
  • 在 GetLogParserScript() 函数中添加对应的 case
  • 由工作流编译系统使用

故障排除

问题:“required file not found in sources”

**原因:**文件未添加到 GetJavaScriptSources() 映射中

**解决方案:**将文件添加到 pkg/workflow/js.go 中的映射

问题:测试失败并提示 “core is not defined”

**原因:**缺少全局模拟对象

**解决方案:**在导入模块前添加适当的模拟对象:

global.core = mockCore;
global.github = mockGithub;
问题:打包器因循环依赖而失败

**原因:**文件 A 引用了文件 B,而文件 B 又引用了文件 A

**解决方案:**调整结构以打破循环依赖,或合并这些模块

问题:重建后更改未生效

**原因:**Go 构建缓存未识别嵌入文件的更改

解决方案:

make clean
make build

参考资料

  • 打包器实现:pkg/workflow/bundler.go
  • JavaScript 源文件注册表:pkg/workflow/js.go
  • 脚本打包:pkg/workflow/scripts.go
  • 现有测试示例:pkg/workflow/js/*.test.cjs
  • GitHub Actions 脚本文档:actions/toolkit

评论

支持 Markdown