From d51538e753e2a5373565d38b0a1520e45c658390 Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 23 Dec 2025 18:30:39 +0100 Subject: [PATCH 1/2] update --- .github/workflows/dev.lock.yml | 3840 ++++++-------------------------- .github/workflows/dev.md | 46 +- 2 files changed, 671 insertions(+), 3215 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 80ea1d66bfb..0e53327f1d8 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -19,12 +19,7 @@ # gh aw compile # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # -# Test upload-asset with Python graph generation -# -# Resolved workflow manifest: -# Imports: -# - shared/gh.md -# - shared/python-dataviz.md +# Update issues on an org-owned Project Board name: "Dev" "on": @@ -138,12 +133,10 @@ jobs: permissions: contents: read issues: read + pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" env: - GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" - GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" - GH_AW_ASSETS_MAX_SIZE_KB: 10240 GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs GH_AW_SAFE_OUTPUTS: /tmp/gh-aw/safeoutputs/outputs.jsonl GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /tmp/gh-aw/safeoutputs/config.json @@ -163,44 +156,6 @@ jobs: mkdir -p /tmp/gh-aw/agent mkdir -p /tmp/gh-aw/sandbox/agent/logs echo "Created /tmp/gh-aw/agent directory for agentic workflow temporary files" - - name: Setup Python environment - run: "# Create working directory for Python scripts\nmkdir -p /tmp/gh-aw/python\nmkdir -p /tmp/gh-aw/python/data\nmkdir -p /tmp/gh-aw/python/charts\nmkdir -p /tmp/gh-aw/python/artifacts\n\necho \"Python environment setup complete\"\necho \"Working directory: /tmp/gh-aw/python\"\necho \"Data directory: /tmp/gh-aw/python/data\"\necho \"Charts directory: /tmp/gh-aw/python/charts\"\necho \"Artifacts directory: /tmp/gh-aw/python/artifacts\"\n" - - name: Install Python scientific libraries - run: "pip install --user --quiet numpy pandas matplotlib seaborn scipy\n\n# Verify installations\npython3 -c \"import numpy; print(f'NumPy {numpy.__version__} installed')\"\npython3 -c \"import pandas; print(f'Pandas {pandas.__version__} installed')\"\npython3 -c \"import matplotlib; print(f'Matplotlib {matplotlib.__version__} installed')\"\npython3 -c \"import seaborn; print(f'Seaborn {seaborn.__version__} installed')\"\npython3 -c \"import scipy; print(f'SciPy {scipy.__version__} installed')\"\n\necho \"All scientific libraries installed successfully\"\n" - - if: always() - name: Upload generated charts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - if-no-files-found: warn - name: data-charts - path: /tmp/gh-aw/python/charts/*.png - retention-days: 30 - - if: always() - name: Upload source files and data - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - if-no-files-found: warn - name: python-source-and-data - path: | - /tmp/gh-aw/python/*.py - /tmp/gh-aw/python/data/* - retention-days: 30 - - # Cache memory file share configuration from frontmatter processed below - - name: Create cache-memory directory - run: | - mkdir -p /tmp/gh-aw/cache-memory - echo "Cache memory directory created at /tmp/gh-aw/cache-memory" - echo "This folder provides persistent file storage across workflow runs" - echo "LLMs and agentic tools can freely read and write files in this directory" - - name: Restore cache memory file share data - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - key: memory-${{ github.workflow }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - restore-keys: | - memory-${{ github.workflow }}- - memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} @@ -330,67 +285,10 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /tmp/gh-aw/safeoutputs/config.json << 'EOF' - {"create_issue":{"max":1},"missing_tool":{"max":0},"noop":{"max":1},"upload_asset":{"max":5}} + {"missing_tool":{"max":0},"noop":{"max":1},"update_project":{"max":10}} EOF cat > /tmp/gh-aw/safeoutputs/tools.json << 'EOF' [ - { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[Dev Test] \".", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "parent": { - "description": "Parent issue number for creating sub-issues. Can be a real issue number (e.g., 42) or a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", - "type": [ - "number", - "string" - ] - }, - "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "type": "string" - }, - "title": { - "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_issue" - }, - { - "description": "Upload a file as a URL-addressable asset that can be referenced in issues, PRs, or comments. The file is stored on an orphaned git branch and returns a permanent URL. Use this for images, diagrams, or other files that need to be embedded in GitHub content. CONSTRAINTS: Maximum 5 asset(s) can be uploaded. Maximum file size: 10240KB. Allowed file extensions: [.png .jpg].", - "inputSchema": { - "additionalProperties": false, - "properties": { - "path": { - "description": "Absolute file path to upload (e.g., '/tmp/chart.png'). Must be under the workspace or /tmp directory. By default, only image files (.png, .jpg, .jpeg) are allowed; other file types require workflow configuration.", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - }, - "name": "upload_asset" - }, { "description": "Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", "inputSchema": { @@ -433,44 +331,88 @@ jobs: "type": "object" }, "name": "noop" + }, + { + "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "inputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "content_type": { + "enum": [ + "issue", + "pull_request" + ] + } + }, + "required": [ + "project", + "content_type", + "content_number" + ] + }, + { + "properties": { + "content_type": { + "const": "draft_issue" + } + }, + "required": [ + "project", + "content_type", + "draft_title" + ] + } + ], + "properties": { + "campaign_id": { + "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", + "type": "string" + }, + "content_number": { + "description": "Issue or pull request number to add to the project (e.g., 123 for issue #123). Required when content_type is 'issue' or 'pull_request'.", + "type": "number" + }, + "content_type": { + "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project.", + "enum": [ + "issue", + "pull_request", + "draft_issue" + ], + "type": "string" + }, + "create_if_missing": { + "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", + "type": "boolean" + }, + "draft_body": { + "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "type": "string" + }, + "draft_title": { + "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "type": "string" + }, + "fields": { + "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", + "type": "object" + }, + "project": { + "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + "type": "string" + } + }, + "type": "object" + }, + "name": "update_project" } ] EOF cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, "missing_tool": { "defaultMax": 20, "fields": { @@ -504,12 +446,40 @@ jobs: } } }, - "upload_asset": { + "update_project": { "defaultMax": 10, "fields": { - "path": { + "campaign_id": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "content_number": { + "optionalPositiveInteger": true + }, + "content_type": { + "type": "string", + "enum": [ + "issue", + "pull_request" + ] + }, + "fields": { + "type": "object" + }, + "issue": { + "optionalPositiveInteger": true + }, + "project": { "required": true, - "type": "string" + "type": "string", + "sanitize": true, + "maxLength": 512, + "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", + "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" + }, + "pull_request": { + "optionalPositiveInteger": true } } } @@ -1852,1614 +1822,100 @@ jobs: EOF chmod +x /tmp/gh-aw/safeoutputs/mcp-server.cjs - - name: Setup Safe Inputs JavaScript and Config + - name: Setup MCPs + env: + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} run: | - mkdir -p /tmp/gh-aw/safe-inputs/logs - cat > /tmp/gh-aw/safe-inputs/read_buffer.cjs << 'EOF_READ_BUFFER' - class ReadBuffer { - constructor() { - this._buffer = null; - } - append(chunk) { - this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; - } - readMessage() { - if (!this._buffer) { - return null; - } - const index = this._buffer.indexOf("\n"); - if (index === -1) { - return null; - } - const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, ""); - this._buffer = this._buffer.subarray(index + 1); - if (line.trim() === "") { - return this.readMessage(); + mkdir -p /tmp/gh-aw/mcp-config + mkdir -p /home/runner/.copilot + cat > /home/runner/.copilot/mcp-config.json << EOF + { + "mcpServers": { + "github": { + "type": "local", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_READ_ONLY=1", + "-e", + "GITHUB_TOOLSETS=context,repos,issues,pull_requests,projects", + "ghcr.io/github/github-mcp-server:v0.26.3" + ], + "tools": ["*"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}" } - try { - return JSON.parse(line); - } catch (error) { - throw new Error(`Parse error: ${error instanceof Error ? error.message : String(error)}`); + }, + "safeoutputs": { + "type": "local", + "command": "node", + "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], + "tools": ["*"], + "env": { + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SERVER_URL": "\${GITHUB_SERVER_URL}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}" } } } - module.exports = { - ReadBuffer, - }; - EOF_READ_BUFFER - cat > /tmp/gh-aw/safe-inputs/mcp_server_core.cjs << 'EOF_MCP_CORE' - const fs = require("fs"); - const path = require("path"); - const { ReadBuffer } = require("./read_buffer.cjs"); - const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); - const encoder = new TextEncoder(); - function initLogFile(server) { - if (server.logFileInitialized || !server.logDir || !server.logFilePath) return; - try { - if (!fs.existsSync(server.logDir)) { - fs.mkdirSync(server.logDir, { recursive: true }); - } - const timestamp = new Date().toISOString(); - fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`); - server.logFileInitialized = true; - } catch { - } - } - function createDebugFunction(server) { - return msg => { - const timestamp = new Date().toISOString(); - const formattedMsg = `[${timestamp}] [${server.serverInfo.name}] ${msg}\n`; - process.stderr.write(formattedMsg); - if (server.logDir && server.logFilePath) { - if (!server.logFileInitialized) { - initLogFile(server); - } - if (server.logFileInitialized) { - try { - fs.appendFileSync(server.logFilePath, formattedMsg); - } catch { - } - } - } - }; - } - function createDebugErrorFunction(server) { - return (prefix, error) => { - const errorMessage = error instanceof Error ? error.message : String(error); - server.debug(`${prefix}${errorMessage}`); - if (error instanceof Error && error.stack) { - server.debug(`${prefix}Stack trace: ${error.stack}`); - } - }; - } - function createWriteMessageFunction(server) { - return obj => { - const json = JSON.stringify(obj); - server.debug(`send: ${json}`); - const message = json + "\n"; - const bytes = encoder.encode(message); - fs.writeSync(1, bytes); - }; - } - function createReplyResultFunction(server) { - return (id, result) => { - if (id === undefined || id === null) return; - const res = { jsonrpc: "2.0", id, result }; - server.writeMessage(res); - }; - } - function createReplyErrorFunction(server) { - return (id, code, message) => { - if (id === undefined || id === null) { - server.debug(`Error for notification: ${message}`); - return; - } - const error = { code, message }; - const res = { - jsonrpc: "2.0", - id, - error, - }; - server.writeMessage(res); - }; - } - function createServer(serverInfo, options = {}) { - const logDir = options.logDir || undefined; - const logFilePath = logDir ? path.join(logDir, "server.log") : undefined; - const server = { - serverInfo, - tools: {}, - debug: () => {}, - debugError: () => {}, - writeMessage: () => {}, - replyResult: () => {}, - replyError: () => {}, - readBuffer: new ReadBuffer(), - logDir, - logFilePath, - logFileInitialized: false, - }; - server.debug = createDebugFunction(server); - server.debugError = createDebugErrorFunction(server); - server.writeMessage = createWriteMessageFunction(server); - server.replyResult = createReplyResultFunction(server); - server.replyError = createReplyErrorFunction(server); - return server; - } - function createWrappedHandler(server, toolName, handlerFn) { - return async args => { - server.debug(` [${toolName}] Invoking handler with args: ${JSON.stringify(args)}`); - try { - const result = await Promise.resolve(handlerFn(args)); - server.debug(` [${toolName}] Handler returned result type: ${typeof result}`); - if (result && typeof result === "object" && Array.isArray(result.content)) { - server.debug(` [${toolName}] Result is already in MCP format`); - return result; - } - let serializedResult; - try { - serializedResult = JSON.stringify(result); - } catch (serializationError) { - server.debugError(` [${toolName}] Serialization error: `, serializationError); - serializedResult = String(result); - } - server.debug(` [${toolName}] Serialized result: ${serializedResult.substring(0, 200)}${serializedResult.length > 200 ? "..." : ""}`); - return { - content: [ - { - type: "text", - text: serializedResult, - }, - ], - }; - } catch (error) { - server.debugError(` [${toolName}] Handler threw error: `, error); - throw error; - } - }; - } - function loadToolHandlers(server, tools, basePath) { - server.debug(`Loading tool handlers...`); - server.debug(` Total tools to process: ${tools.length}`); - server.debug(` Base path: ${basePath || "(not specified)"}`); - let loadedCount = 0; - let skippedCount = 0; - let errorCount = 0; - for (const tool of tools) { - const toolName = tool.name || "(unnamed)"; - if (!tool.handler) { - server.debug(` [${toolName}] No handler path specified, skipping handler load`); - skippedCount++; - continue; - } - const handlerPath = tool.handler; - server.debug(` [${toolName}] Handler path specified: ${handlerPath}`); - let resolvedPath = handlerPath; - if (basePath && !path.isAbsolute(handlerPath)) { - resolvedPath = path.resolve(basePath, handlerPath); - server.debug(` [${toolName}] Resolved relative path to: ${resolvedPath}`); - const normalizedBase = path.resolve(basePath); - const normalizedResolved = path.resolve(resolvedPath); - if (!normalizedResolved.startsWith(normalizedBase + path.sep) && normalizedResolved !== normalizedBase) { - server.debug(` [${toolName}] ERROR: Handler path escapes base directory: ${resolvedPath} is not within ${basePath}`); - errorCount++; - continue; - } - } else if (path.isAbsolute(handlerPath)) { - server.debug(` [${toolName}] Using absolute path (bypasses basePath validation): ${handlerPath}`); - } - tool.handlerPath = handlerPath; - try { - server.debug(` [${toolName}] Loading handler from: ${resolvedPath}`); - if (!fs.existsSync(resolvedPath)) { - server.debug(` [${toolName}] ERROR: Handler file does not exist: ${resolvedPath}`); - errorCount++; - continue; - } - const ext = path.extname(resolvedPath).toLowerCase(); - server.debug(` [${toolName}] Handler file extension: ${ext}`); - if (ext === ".sh") { - server.debug(` [${toolName}] Detected shell script handler`); - try { - fs.accessSync(resolvedPath, fs.constants.X_OK); - server.debug(` [${toolName}] Shell script is executable`); - } catch { - try { - fs.chmodSync(resolvedPath, 0o755); - server.debug(` [${toolName}] Made shell script executable`); - } catch (chmodError) { - server.debugError(` [${toolName}] Warning: Could not make shell script executable: `, chmodError); - } - } - const { createShellHandler } = require("./mcp_handler_shell.cjs"); - const timeout = tool.timeout || 60; - tool.handler = createShellHandler(server, toolName, resolvedPath, timeout); - loadedCount++; - server.debug(` [${toolName}] Shell handler created successfully with timeout: ${timeout}s`); - } else if (ext === ".py") { - server.debug(` [${toolName}] Detected Python script handler`); - try { - fs.accessSync(resolvedPath, fs.constants.X_OK); - server.debug(` [${toolName}] Python script is executable`); - } catch { - try { - fs.chmodSync(resolvedPath, 0o755); - server.debug(` [${toolName}] Made Python script executable`); - } catch (chmodError) { - server.debugError(` [${toolName}] Warning: Could not make Python script executable: `, chmodError); - } - } - const { createPythonHandler } = require("./mcp_handler_python.cjs"); - const timeout = tool.timeout || 60; - tool.handler = createPythonHandler(server, toolName, resolvedPath, timeout); - loadedCount++; - server.debug(` [${toolName}] Python handler created successfully with timeout: ${timeout}s`); - } else { - server.debug(` [${toolName}] Loading JavaScript handler module`); - const handlerModule = require(resolvedPath); - server.debug(` [${toolName}] Handler module loaded successfully`); - server.debug(` [${toolName}] Module type: ${typeof handlerModule}`); - let handlerFn = handlerModule; - if (handlerModule && typeof handlerModule === "object" && typeof handlerModule.default === "function") { - handlerFn = handlerModule.default; - server.debug(` [${toolName}] Using module.default export`); - } - if (typeof handlerFn !== "function") { - server.debug(` [${toolName}] ERROR: Handler is not a function, got: ${typeof handlerFn}`); - server.debug(` [${toolName}] Module keys: ${Object.keys(handlerModule || {}).join(", ") || "(none)"}`); - errorCount++; - continue; - } - server.debug(` [${toolName}] Handler function validated successfully`); - server.debug(` [${toolName}] Handler function name: ${handlerFn.name || "(anonymous)"}`); - tool.handler = createWrappedHandler(server, toolName, handlerFn); - loadedCount++; - server.debug(` [${toolName}] JavaScript handler loaded and wrapped successfully`); - } - } catch (error) { - server.debugError(` [${toolName}] ERROR loading handler: `, error); - errorCount++; - } - } - server.debug(`Handler loading complete:`); - server.debug(` Loaded: ${loadedCount}`); - server.debug(` Skipped (no handler path): ${skippedCount}`); - server.debug(` Errors: ${errorCount}`); - return tools; - } - function registerTool(server, tool) { - const normalizedName = normalizeTool(tool.name); - server.tools[normalizedName] = { - ...tool, - name: normalizedName, - }; - server.debug(`Registered tool: ${normalizedName}`); - } - function normalizeTool(name) { - return name.replace(/-/g, "_").toLowerCase(); - } - async function handleRequest(server, request, defaultHandler) { - const { id, method, params } = request; - try { - if (!("id" in request)) { - return null; - } - let result; - if (method === "initialize") { - const protocolVersion = params?.protocolVersion || "2024-11-05"; - result = { - protocolVersion, - serverInfo: server.serverInfo, - capabilities: { - tools: {}, - }, - }; - } else if (method === "ping") { - result = {}; - } else if (method === "tools/list") { - const list = []; - Object.values(server.tools).forEach(tool => { - const toolDef = { - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - }; - list.push(toolDef); - }); - result = { tools: list }; - } else if (method === "tools/call") { - const name = params?.name; - const args = params?.arguments ?? {}; - if (!name || typeof name !== "string") { - throw { - code: -32602, - message: "Invalid params: 'name' must be a string", - }; - } - const tool = server.tools[normalizeTool(name)]; - if (!tool) { - throw { - code: -32602, - message: `Tool '${name}' not found`, - }; - } - let handler = tool.handler; - if (!handler && defaultHandler) { - handler = defaultHandler(tool.name); - } - if (!handler) { - throw { - code: -32603, - message: `No handler for tool: ${name}`, - }; - } - const missing = validateRequiredFields(args, tool.inputSchema); - if (missing.length) { - throw { - code: -32602, - message: `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`, - }; - } - const handlerResult = await Promise.resolve(handler(args)); - const content = handlerResult && handlerResult.content ? handlerResult.content : []; - result = { content, isError: false }; - } else if (/^notifications\//.test(method)) { - return null; - } else { - throw { - code: -32601, - message: `Method not found: ${method}`, - }; - } - return { - jsonrpc: "2.0", - id, - result, - }; - } catch (error) { - const err = error; - return { - jsonrpc: "2.0", - id, - error: { - code: err.code || -32603, - message: err.message || "Internal error", - }, - }; - } - } - async function handleMessage(server, req, defaultHandler) { - if (!req || typeof req !== "object") { - server.debug(`Invalid message: not an object`); - return; - } - if (req.jsonrpc !== "2.0") { - server.debug(`Invalid message: missing or invalid jsonrpc field`); - return; - } - const { id, method, params } = req; - if (!method || typeof method !== "string") { - server.replyError(id, -32600, "Invalid Request: method must be a string"); - return; - } - try { - if (method === "initialize") { - const clientInfo = params?.clientInfo ?? {}; - server.debug(`client info: ${JSON.stringify(clientInfo)}`); - const protocolVersion = params?.protocolVersion ?? undefined; - const result = { - serverInfo: server.serverInfo, - ...(protocolVersion ? { protocolVersion } : {}), - capabilities: { - tools: {}, - }, - }; - server.replyResult(id, result); - } else if (method === "tools/list") { - const list = []; - Object.values(server.tools).forEach(tool => { - const toolDef = { - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - }; - list.push(toolDef); - }); - server.replyResult(id, { tools: list }); - } else if (method === "tools/call") { - const name = params?.name; - const args = params?.arguments ?? {}; - if (!name || typeof name !== "string") { - server.replyError(id, -32602, "Invalid params: 'name' must be a string"); - return; - } - const tool = server.tools[normalizeTool(name)]; - if (!tool) { - server.replyError(id, -32601, `Tool not found: ${name} (${normalizeTool(name)})`); - return; - } - let handler = tool.handler; - if (!handler && defaultHandler) { - handler = defaultHandler(tool.name); - } - if (!handler) { - server.replyError(id, -32603, `No handler for tool: ${name}`); - return; - } - const missing = validateRequiredFields(args, tool.inputSchema); - if (missing.length) { - server.replyError(id, -32602, `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); - return; - } - server.debug(`Calling handler for tool: ${name}`); - const result = await Promise.resolve(handler(args)); - server.debug(`Handler returned for tool: ${name}`); - const content = result && result.content ? result.content : []; - server.replyResult(id, { content, isError: false }); - } else if (/^notifications\//.test(method)) { - server.debug(`ignore ${method}`); - } else { - server.replyError(id, -32601, `Method not found: ${method}`); - } - } catch (e) { - server.replyError(id, -32603, e instanceof Error ? e.message : String(e)); - } - } - async function processReadBuffer(server, defaultHandler) { - while (true) { - try { - const message = server.readBuffer.readMessage(); - if (!message) { - break; - } - server.debug(`recv: ${JSON.stringify(message)}`); - await handleMessage(server, message, defaultHandler); - } catch (error) { - server.debug(`Parse error: ${error instanceof Error ? error.message : String(error)}`); - } - } - } - function start(server, options = {}) { - const { defaultHandler } = options; - server.debug(`v${server.serverInfo.version} ready on stdio`); - server.debug(` tools: ${Object.keys(server.tools).join(", ")}`); - if (!Object.keys(server.tools).length) { - throw new Error("No tools registered"); - } - const onData = async chunk => { - server.readBuffer.append(chunk); - await processReadBuffer(server, defaultHandler); - }; - process.stdin.on("data", onData); - process.stdin.on("error", err => server.debug(`stdin error: ${err}`)); - process.stdin.resume(); - server.debug(`listening...`); - } - module.exports = { - createServer, - registerTool, - normalizeTool, - handleRequest, - handleMessage, - processReadBuffer, - start, - loadToolHandlers, - }; - EOF_MCP_CORE - cat > /tmp/gh-aw/safe-inputs/mcp_http_transport.cjs << 'EOF_MCP_HTTP_TRANSPORT' - const http = require("http"); - const { randomUUID } = require("crypto"); - const { createServer, registerTool, handleRequest } = require("./mcp_server_core.cjs"); - class MCPServer { - constructor(serverInfo, options = {}) { - this._coreServer = createServer(serverInfo, options); - this.serverInfo = serverInfo; - this.capabilities = options.capabilities || { tools: {} }; - this.tools = new Map(); - this.transport = null; - this.initialized = false; - } - tool(name, description, inputSchema, handler) { - this.tools.set(name, { - name, - description, - inputSchema, - handler, - }); - registerTool(this._coreServer, { - name, - description, - inputSchema, - handler, - }); - } - async connect(transport) { - this.transport = transport; - transport.setServer(this); - await transport.start(); - } - async handleRequest(request) { - if (request.method === "initialize") { - this.initialized = true; - } - return handleRequest(this._coreServer, request); - } - } - class MCPHTTPTransport { - constructor(options = {}) { - this.sessionIdGenerator = options.sessionIdGenerator; - this.enableJsonResponse = options.enableJsonResponse !== false; - this.enableDnsRebindingProtection = options.enableDnsRebindingProtection || false; - this.server = null; - this.sessionId = null; - this.started = false; - } - setServer(server) { - this.server = server; - } - async start() { - if (this.started) { - throw new Error("Transport already started"); - } - this.started = true; - } - async handleRequest(req, res, parsedBody) { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id"); - if (req.method === "OPTIONS") { - res.writeHead(200); - res.end(); - return; - } - if (req.method !== "POST") { - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Method not allowed" })); - return; - } - try { - let body = parsedBody; - if (!body) { - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - const bodyStr = Buffer.concat(chunks).toString(); - try { - body = bodyStr ? JSON.parse(bodyStr) : null; - } catch (parseError) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32700, - message: "Parse error: Invalid JSON in request body", - }, - id: null, - }) - ); - return; - } - } - if (!body) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32600, - message: "Invalid Request: Empty request body", - }, - id: null, - }) - ); - return; - } - if (!body.jsonrpc || body.jsonrpc !== "2.0") { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32600, - message: "Invalid Request: jsonrpc must be '2.0'", - }, - id: body.id || null, - }) - ); - return; - } - if (this.sessionIdGenerator) { - if (body.method === "initialize") { - this.sessionId = this.sessionIdGenerator(); - } else { - const requestSessionId = req.headers["mcp-session-id"]; - if (!requestSessionId) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32600, - message: "Invalid Request: Missing Mcp-Session-Id header", - }, - id: body.id || null, - }) - ); - return; - } - if (requestSessionId !== this.sessionId) { - res.writeHead(404, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32001, - message: "Session not found", - }, - id: body.id || null, - }) - ); - return; - } - } - } - const response = await this.server.handleRequest(body); - if (response === null) { - res.writeHead(204); - res.end(); - return; - } - const headers = { "Content-Type": "application/json" }; - if (this.sessionId) { - headers["mcp-session-id"] = this.sessionId; - } - res.writeHead(200, headers); - res.end(JSON.stringify(response)); - } catch (error) { - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32603, - message: error instanceof Error ? error.message : String(error), - }, - id: null, - }) - ); - } - } - } - } - module.exports = { - MCPServer, - MCPHTTPTransport, - }; - EOF_MCP_HTTP_TRANSPORT - cat > /tmp/gh-aw/safe-inputs/mcp_logger.cjs << 'EOF_MCP_LOGGER' - function createLogger(serverName) { - const logger = { - debug: msg => { - const timestamp = new Date().toISOString(); - process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); - }, - debugError: (prefix, error) => { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.debug(`${prefix}${errorMessage}`); - if (error instanceof Error && error.stack) { - logger.debug(`${prefix}Stack trace: ${error.stack}`); - } - }, - }; - return logger; - } - module.exports = { - createLogger, - }; - EOF_MCP_LOGGER - cat > /tmp/gh-aw/safe-inputs/mcp_handler_shell.cjs << 'EOF_HANDLER_SHELL' - const fs = require("fs"); - const path = require("path"); - const { execFile } = require("child_process"); - const os = require("os"); - function createShellHandler(server, toolName, scriptPath, timeoutSeconds = 60) { - return async args => { - server.debug(` [${toolName}] Invoking shell handler: ${scriptPath}`); - server.debug(` [${toolName}] Shell handler args: ${JSON.stringify(args)}`); - server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`); - const env = { ...process.env }; - for (const [key, value] of Object.entries(args || {})) { - const envKey = `INPUT_${key.toUpperCase().replace(/-/g, "_")}`; - env[envKey] = String(value); - server.debug(` [${toolName}] Set env: ${envKey}=${String(value).substring(0, 100)}${String(value).length > 100 ? "..." : ""}`); - } - const outputFile = path.join(os.tmpdir(), `mcp-shell-output-${Date.now()}-${Math.random().toString(36).substring(2)}.txt`); - env.GITHUB_OUTPUT = outputFile; - server.debug(` [${toolName}] Output file: ${outputFile}`); - fs.writeFileSync(outputFile, ""); - return new Promise((resolve, reject) => { - server.debug(` [${toolName}] Executing shell script...`); - execFile( - scriptPath, - [], - { - env, - timeout: timeoutSeconds * 1000, - maxBuffer: 10 * 1024 * 1024, - }, - (error, stdout, stderr) => { - if (stdout) { - server.debug(` [${toolName}] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`); - } - if (stderr) { - server.debug(` [${toolName}] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`); - } - if (error) { - server.debugError(` [${toolName}] Shell script error: `, error); - try { - if (fs.existsSync(outputFile)) { - fs.unlinkSync(outputFile); - } - } catch { - } - reject(error); - return; - } - const outputs = {}; - try { - if (fs.existsSync(outputFile)) { - const outputContent = fs.readFileSync(outputFile, "utf-8"); - server.debug(` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`); - const lines = outputContent.split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed && trimmed.includes("=")) { - const eqIndex = trimmed.indexOf("="); - const key = trimmed.substring(0, eqIndex); - const value = trimmed.substring(eqIndex + 1); - outputs[key] = value; - server.debug(` [${toolName}] Parsed output: ${key}=${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`); - } - } - } - } catch (readError) { - server.debugError(` [${toolName}] Error reading output file: `, readError); - } - try { - if (fs.existsSync(outputFile)) { - fs.unlinkSync(outputFile); - } - } catch { - } - const result = { - stdout: stdout || "", - stderr: stderr || "", - outputs, - }; - server.debug(` [${toolName}] Shell handler completed, outputs: ${Object.keys(outputs).join(", ") || "(none)"}`); - resolve({ - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }); - } - ); - }); - }; - } - module.exports = { - createShellHandler, - }; - EOF_HANDLER_SHELL - cat > /tmp/gh-aw/safe-inputs/mcp_handler_python.cjs << 'EOF_HANDLER_PYTHON' - const { execFile } = require("child_process"); - function createPythonHandler(server, toolName, scriptPath, timeoutSeconds = 60) { - return async args => { - server.debug(` [${toolName}] Invoking Python handler: ${scriptPath}`); - server.debug(` [${toolName}] Python handler args: ${JSON.stringify(args)}`); - server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`); - const inputJson = JSON.stringify(args || {}); - server.debug(` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`); - return new Promise((resolve, reject) => { - server.debug(` [${toolName}] Executing Python script...`); - const child = execFile( - "python3", - [scriptPath], - { - env: process.env, - timeout: timeoutSeconds * 1000, - maxBuffer: 10 * 1024 * 1024, - }, - (error, stdout, stderr) => { - if (stdout) { - server.debug(` [${toolName}] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`); - } - if (stderr) { - server.debug(` [${toolName}] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`); - } - if (error) { - server.debugError(` [${toolName}] Python script error: `, error); - reject(error); - return; - } - let result; - try { - if (stdout && stdout.trim()) { - result = JSON.parse(stdout.trim()); - } else { - result = { stdout: stdout || "", stderr: stderr || "" }; - } - } catch (parseError) { - server.debug(` [${toolName}] Output is not JSON, returning as text`); - result = { stdout: stdout || "", stderr: stderr || "" }; - } - server.debug(` [${toolName}] Python handler completed successfully`); - resolve({ - content: [ - { - type: "text", - text: JSON.stringify(result), - }, - ], - }); - } - ); - if (child.stdin) { - child.stdin.write(inputJson); - child.stdin.end(); - } - }); - }; - } - module.exports = { - createPythonHandler, - }; - EOF_HANDLER_PYTHON - cat > /tmp/gh-aw/safe-inputs/safe_inputs_config_loader.cjs << 'EOF_CONFIG_LOADER' - const fs = require("fs"); - function loadConfig(configPath) { - if (!fs.existsSync(configPath)) { - throw new Error(`Configuration file not found: ${configPath}`); - } - const configContent = fs.readFileSync(configPath, "utf-8"); - const config = JSON.parse(configContent); - if (!config.tools || !Array.isArray(config.tools)) { - throw new Error("Configuration must contain a 'tools' array"); - } - return config; - } - module.exports = { - loadConfig, - }; - EOF_CONFIG_LOADER - cat > /tmp/gh-aw/safe-inputs/safe_inputs_tool_factory.cjs << 'EOF_TOOL_FACTORY' - function createToolConfig(name, description, inputSchema, handlerPath) { - return { - name, - description, - inputSchema, - handler: handlerPath, - }; - } - module.exports = { - createToolConfig, - }; - EOF_TOOL_FACTORY - cat > /tmp/gh-aw/safe-inputs/safe_inputs_validation.cjs << 'EOF_VALIDATION' - function validateRequiredFields(args, inputSchema) { - const requiredFields = inputSchema && Array.isArray(inputSchema.required) ? inputSchema.required : []; - if (!requiredFields.length) { - return []; - } - const missing = requiredFields.filter(f => { - const value = args[f]; - return value === undefined || value === null || (typeof value === "string" && value.trim() === ""); - }); - return missing; - } - module.exports = { - validateRequiredFields, - }; - EOF_VALIDATION - cat > /tmp/gh-aw/safe-inputs/safe_inputs_bootstrap.cjs << 'EOF_BOOTSTRAP' - const path = require("path"); - const fs = require("fs"); - const { loadConfig } = require("./safe_inputs_config_loader.cjs"); - const { loadToolHandlers } = require("./mcp_server_core.cjs"); - function bootstrapSafeInputsServer(configPath, logger) { - logger.debug(`Loading safe-inputs configuration from: ${configPath}`); - const config = loadConfig(configPath); - const basePath = path.dirname(configPath); - logger.debug(`Base path for handlers: ${basePath}`); - logger.debug(`Tools to load: ${config.tools.length}`); - const tools = loadToolHandlers(logger, config.tools, basePath); - return { config, basePath, tools }; - } - function cleanupConfigFile(configPath, logger) { - try { - if (fs.existsSync(configPath)) { - fs.unlinkSync(configPath); - logger.debug(`Deleted configuration file: ${configPath}`); - } - } catch (error) { - logger.debugError(`Warning: Could not delete configuration file: `, error); - } - } - module.exports = { - bootstrapSafeInputsServer, - cleanupConfigFile, - }; - EOF_BOOTSTRAP - cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server.cjs << 'EOF_SAFE_INPUTS_SERVER' - const { createServer, registerTool, start } = require("./mcp_server_core.cjs"); - const { loadConfig } = require("./safe_inputs_config_loader.cjs"); - const { createToolConfig } = require("./safe_inputs_tool_factory.cjs"); - const { bootstrapSafeInputsServer, cleanupConfigFile } = require("./safe_inputs_bootstrap.cjs"); - function startSafeInputsServer(configPath, options = {}) { - const logDir = options.logDir || undefined; - const server = createServer({ name: "safeinputs", version: "1.0.0" }, { logDir }); - const { config, tools } = bootstrapSafeInputsServer(configPath, server); - server.serverInfo.name = config.serverName || "safeinputs"; - server.serverInfo.version = config.version || "1.0.0"; - if (!options.logDir && config.logDir) { - server.logDir = config.logDir; - } - for (const tool of tools) { - registerTool(server, tool); - } - if (!options.skipCleanup) { - cleanupConfigFile(configPath, server); - } - start(server); - } - if (require.main === module) { - const args = process.argv.slice(2); - if (args.length < 1) { - console.error("Usage: node safe_inputs_mcp_server.cjs [--log-dir ]"); - process.exit(1); - } - const configPath = args[0]; - const options = {}; - for (let i = 1; i < args.length; i++) { - if (args[i] === "--log-dir" && args[i + 1]) { - options.logDir = args[i + 1]; - i++; - } - } - try { - startSafeInputsServer(configPath, options); - } catch (error) { - console.error(`Error starting safe-inputs server: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - } - } - module.exports = { - startSafeInputsServer, - loadConfig, - createToolConfig, - }; - EOF_SAFE_INPUTS_SERVER - cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' - const http = require("http"); - const { randomUUID } = require("crypto"); - const { MCPServer, MCPHTTPTransport } = require("./mcp_http_transport.cjs"); - const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); - const { createLogger } = require("./mcp_logger.cjs"); - const { bootstrapSafeInputsServer, cleanupConfigFile } = require("./safe_inputs_bootstrap.cjs"); - function createMCPServer(configPath, options = {}) { - const logger = createLogger("safeinputs"); - logger.debug(`=== Creating MCP Server ===`); - logger.debug(`Configuration file: ${configPath}`); - const { config, tools } = bootstrapSafeInputsServer(configPath, logger); - const serverName = config.serverName || "safeinputs"; - const version = config.version || "1.0.0"; - logger.debug(`Server name: ${serverName}`); - logger.debug(`Server version: ${version}`); - const server = new MCPServer( - { - name: serverName, - version: version, - }, - { - capabilities: { - tools: {}, - }, - } - ); - logger.debug(`Registering tools with MCP server...`); - let registeredCount = 0; - let skippedCount = 0; - for (const tool of tools) { - if (!tool.handler) { - logger.debug(`Skipping tool ${tool.name} - no handler loaded`); - skippedCount++; - continue; - } - logger.debug(`Registering tool: ${tool.name}`); - server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { - logger.debug(`Calling handler for tool: ${tool.name}`); - const missing = validateRequiredFields(args, tool.inputSchema); - if (missing.length) { - throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); - } - const result = await Promise.resolve(tool.handler(args)); - logger.debug(`Handler returned for tool: ${tool.name}`); - const content = result && result.content ? result.content : []; - return { content, isError: false }; - }); - registeredCount++; - } - logger.debug(`Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped`); - logger.debug(`=== MCP Server Creation Complete ===`); - cleanupConfigFile(configPath, logger); - return { server, config, logger }; - } - async function startHttpServer(configPath, options = {}) { - const port = options.port || 3000; - const stateless = options.stateless || false; - const logger = createLogger("safe-inputs-startup"); - logger.debug(`=== Starting Safe Inputs MCP HTTP Server ===`); - logger.debug(`Configuration file: ${configPath}`); - logger.debug(`Port: ${port}`); - logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); - logger.debug(`Environment: NODE_VERSION=${process.version}, PLATFORM=${process.platform}`); - try { - const { server, config, logger: mcpLogger } = createMCPServer(configPath, { logDir: options.logDir }); - Object.assign(logger, mcpLogger); - logger.debug(`MCP server created successfully`); - logger.debug(`Server name: ${config.serverName || "safeinputs"}`); - logger.debug(`Server version: ${config.version || "1.0.0"}`); - logger.debug(`Tools configured: ${config.tools.length}`); - logger.debug(`Creating HTTP transport...`); - const transport = new MCPHTTPTransport({ - sessionIdGenerator: stateless ? undefined : () => randomUUID(), - enableJsonResponse: true, - enableDnsRebindingProtection: false, - }); - logger.debug(`HTTP transport created`); - logger.debug(`Connecting server to transport...`); - await server.connect(transport); - logger.debug(`Server connected to transport successfully`); - logger.debug(`Creating HTTP server...`); - const httpServer = http.createServer(async (req, res) => { - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); - if (req.method === "OPTIONS") { - res.writeHead(200); - res.end(); - return; - } - if (req.method === "GET" && req.url === "/health") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - status: "ok", - server: config.serverName || "safeinputs", - version: config.version || "1.0.0", - tools: config.tools.length, - }) - ); - return; - } - if (req.method !== "POST") { - res.writeHead(405, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Method not allowed" })); - return; - } - try { - let body = null; - if (req.method === "POST") { - const chunks = []; - for await (const chunk of req) { - chunks.push(chunk); - } - const bodyStr = Buffer.concat(chunks).toString(); - try { - body = bodyStr ? JSON.parse(bodyStr) : null; - } catch (parseError) { - res.writeHead(400, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32700, - message: "Parse error: Invalid JSON in request body", - }, - id: null, - }) - ); - return; - } - } - await transport.handleRequest(req, res, body); - } catch (error) { - logger.debugError("Error handling request: ", error); - if (!res.headersSent) { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - error: { - code: -32603, - message: error instanceof Error ? error.message : String(error), - }, - id: null, - }) - ); - } - } - }); - logger.debug(`Attempting to bind to port ${port}...`); - httpServer.listen(port, () => { - logger.debug(`=== Safe Inputs MCP HTTP Server Started Successfully ===`); - logger.debug(`HTTP server listening on http://localhost:${port}`); - logger.debug(`MCP endpoint: POST http://localhost:${port}/`); - logger.debug(`Server name: ${config.serverName || "safeinputs"}`); - logger.debug(`Server version: ${config.version || "1.0.0"}`); - logger.debug(`Tools available: ${config.tools.length}`); - logger.debug(`Server is ready to accept requests`); - }); - httpServer.on("error", error => { - if (error.code === "EADDRINUSE") { - logger.debugError(`ERROR: Port ${port} is already in use. `, error); - } else if (error.code === "EACCES") { - logger.debugError(`ERROR: Permission denied to bind to port ${port}. `, error); - } else { - logger.debugError(`ERROR: Failed to start HTTP server: `, error); - } - process.exit(1); - }); - process.on("SIGINT", () => { - logger.debug("Received SIGINT, shutting down..."); - httpServer.close(() => { - logger.debug("HTTP server closed"); - process.exit(0); - }); - }); - process.on("SIGTERM", () => { - logger.debug("Received SIGTERM, shutting down..."); - httpServer.close(() => { - logger.debug("HTTP server closed"); - process.exit(0); - }); - }); - return httpServer; - } catch (error) { - const errorLogger = createLogger("safe-inputs-startup-error"); - errorLogger.debug(`=== FATAL ERROR: Failed to start Safe Inputs MCP HTTP Server ===`); - errorLogger.debug(`Error type: ${error.constructor.name}`); - errorLogger.debug(`Error message: ${error.message}`); - if (error.stack) { - errorLogger.debug(`Stack trace:\n${error.stack}`); - } - if (error.code) { - errorLogger.debug(`Error code: ${error.code}`); - } - errorLogger.debug(`Configuration file: ${configPath}`); - errorLogger.debug(`Port: ${port}`); - throw error; - } - } - if (require.main === module) { - const args = process.argv.slice(2); - if (args.length < 1) { - console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); - process.exit(1); - } - const configPath = args[0]; - const options = { - port: 3000, - stateless: false, - logDir: undefined, - }; - for (let i = 1; i < args.length; i++) { - if (args[i] === "--port" && args[i + 1]) { - options.port = parseInt(args[i + 1], 10); - i++; - } else if (args[i] === "--stateless") { - options.stateless = true; - } else if (args[i] === "--log-dir" && args[i + 1]) { - options.logDir = args[i + 1]; - i++; - } - } - startHttpServer(configPath, options).catch(error => { - console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - }); - } - module.exports = { - startHttpServer, - createMCPServer, - }; - EOF_SAFE_INPUTS_SERVER_HTTP - cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' - { - "serverName": "safeinputs", - "version": "1.0.0", - "logDir": "/tmp/gh-aw/safe-inputs/logs", - "tools": [ - { - "name": "gh", - "description": "Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh \u003cargs\u003e. Use single quotes ' for complex args to avoid shell interpretation issues.", - "inputSchema": { - "properties": { - "args": { - "description": "Arguments to pass to gh CLI (without the 'gh' prefix). Examples: 'pr list --limit 5', 'issue view 123', 'api repos/{owner}/{repo}'", - "type": "string" - } - }, - "required": [ - "args" - ], - "type": "object" - }, - "handler": "gh.sh", - "env": { - "GH_AW_GH_TOKEN": "GH_AW_GH_TOKEN", - "GH_DEBUG": "GH_DEBUG" - }, - "timeout": 60 - } - ] - } - EOF_TOOLS_JSON - cat > /tmp/gh-aw/safe-inputs/mcp-server.cjs << 'EOFSI' - const path = require("path"); - const { startHttpServer } = require("./safe_inputs_mcp_server_http.cjs"); - const configPath = path.join(__dirname, "tools.json"); - const port = parseInt(process.env.GH_AW_SAFE_INPUTS_PORT || "3000", 10); - const apiKey = process.env.GH_AW_SAFE_INPUTS_API_KEY || ""; - startHttpServer(configPath, { - port: port, - stateless: false, - logDir: "/tmp/gh-aw/safe-inputs/logs" - }).catch(error => { - console.error("Failed to start safe-inputs HTTP server:", error); - process.exit(1); - }); - EOFSI - chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs - - - name: Setup Safe Inputs Tool Files - run: | - cat > /tmp/gh-aw/safe-inputs/gh.sh << 'EOFSH_gh' - #!/bin/bash - # Auto-generated safe-input tool: gh - # Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues. - - set -euo pipefail - - echo "gh $INPUT_ARGS" - echo " token: ${GH_AW_GH_TOKEN:0:6}..." - GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS - - EOFSH_gh - chmod +x /tmp/gh-aw/safe-inputs/gh.sh - - - name: Generate Safe Inputs MCP Server Config - id: safe-inputs-config - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - function generateSafeInputsConfig({ core, crypto }) { - const apiKeyBuffer = crypto.randomBytes(45); - const apiKey = apiKeyBuffer.toString("base64").replace(/[/+=]/g, ""); - const port = 3000; - core.setOutput("safe_inputs_api_key", apiKey); - core.setOutput("safe_inputs_port", port.toString()); - core.info(`Safe Inputs MCP server will run on port ${port}`); - return { apiKey, port }; - } - - // Execute the function - const crypto = require('crypto'); - generateSafeInputsConfig({ core, crypto }); - - - name: Start Safe Inputs MCP HTTP Server - id: safe-inputs-start - run: | - # Set environment variables for the server - export GH_AW_SAFE_INPUTS_PORT=${{ steps.safe-inputs-config.outputs.safe_inputs_port }} - export GH_AW_SAFE_INPUTS_API_KEY=${{ steps.safe-inputs-config.outputs.safe_inputs_api_key }} - - export GH_AW_GH_TOKEN="${GH_AW_GH_TOKEN}" - export GH_DEBUG="${GH_DEBUG}" - - cd /tmp/gh-aw/safe-inputs - # Verify required files exist - echo "Verifying safe-inputs setup..." - if [ ! -f mcp-server.cjs ]; then - echo "ERROR: mcp-server.cjs not found in /tmp/gh-aw/safe-inputs" - ls -la /tmp/gh-aw/safe-inputs/ - exit 1 - fi - if [ ! -f tools.json ]; then - echo "ERROR: tools.json not found in /tmp/gh-aw/safe-inputs" - ls -la /tmp/gh-aw/safe-inputs/ - exit 1 - fi - echo "Configuration files verified" - # Log environment configuration - echo "Server configuration:" - echo " Port: $GH_AW_SAFE_INPUTS_PORT" - echo " API Key: ${GH_AW_SAFE_INPUTS_API_KEY:0:8}..." - echo " Working directory: $(pwd)" - # Ensure logs directory exists - mkdir -p /tmp/gh-aw/safe-inputs/logs - # Create initial server.log file for artifact upload - { - echo "Safe Inputs MCP Server Log" - echo "Start time: $(date)" - echo "===========================================" - echo "" - } > /tmp/gh-aw/safe-inputs/logs/server.log - # Start the HTTP server in the background - echo "Starting safe-inputs MCP HTTP server..." - node mcp-server.cjs >> /tmp/gh-aw/safe-inputs/logs/server.log 2>&1 & - SERVER_PID=$! - echo "Started safe-inputs MCP server with PID $SERVER_PID" - # Wait for server to be ready (max 10 seconds) - echo "Waiting for server to become ready..." - for i in {1..10}; do - # Check if process is still running - if ! kill -0 $SERVER_PID 2>/dev/null; then - echo "ERROR: Server process $SERVER_PID has died" - echo "Server log contents:" - cat /tmp/gh-aw/safe-inputs/logs/server.log - exit 1 - fi - # Check if server is responding - if curl -s -f "http://localhost:$GH_AW_SAFE_INPUTS_PORT/health" > /dev/null 2>&1; then - echo "Safe Inputs MCP server is ready (attempt $i/10)" - break - fi - if [ "$i" -eq 10 ]; then - echo "ERROR: Safe Inputs MCP server failed to start after 10 seconds" - echo "Process status: $(pgrep -f 'mcp-server.cjs' || echo 'not running')" - echo "Server log contents:" - cat /tmp/gh-aw/safe-inputs/logs/server.log - echo "Checking port availability:" - netstat -tuln | grep "$GH_AW_SAFE_INPUTS_PORT" || echo "Port $GH_AW_SAFE_INPUTS_PORT not listening" - exit 1 - fi - echo "Waiting for server... (attempt $i/10)" - sleep 1 - done - # Output the configuration for the MCP client - echo "port=$GH_AW_SAFE_INPUTS_PORT" >> "$GITHUB_OUTPUT" - echo "api_key=$GH_AW_SAFE_INPUTS_API_KEY" >> "$GITHUB_OUTPUT" - - - name: Setup MCPs - env: - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ASSETS_BRANCH: ${{ env.GH_AW_ASSETS_BRANCH }} - GH_AW_ASSETS_MAX_SIZE_KB: ${{ env.GH_AW_ASSETS_MAX_SIZE_KB }} - GH_AW_ASSETS_ALLOWED_EXTS: ${{ env.GH_AW_ASSETS_ALLOWED_EXTS }} - GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-start.outputs.port }} - GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-start.outputs.api_key }} - GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_DEBUG: 1 - run: | - mkdir -p /tmp/gh-aw/mcp-config - mkdir -p /home/runner/.copilot - cat > /home/runner/.copilot/mcp-config.json << EOF - { - "mcpServers": { - "github": { - "type": "local", - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "-e", - "GITHUB_READ_ONLY=1", - "-e", - "GITHUB_TOOLSETS=issues", - "ghcr.io/github/github-mcp-server:v0.26.3" - ], - "tools": ["*"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}" - } - }, - "safeinputs": { - "type": "http", - "url": "http://host.docker.internal:\${GH_AW_SAFE_INPUTS_PORT}", - "headers": { - "Authorization": "Bearer \${GH_AW_SAFE_INPUTS_API_KEY}" - }, - "tools": ["*"], - "env": { - "GH_AW_SAFE_INPUTS_PORT": "\${GH_AW_SAFE_INPUTS_PORT}", - "GH_AW_SAFE_INPUTS_API_KEY": "\${GH_AW_SAFE_INPUTS_API_KEY}", - "GH_AW_GH_TOKEN": "\${GH_AW_GH_TOKEN}", - "GH_DEBUG": "\${GH_DEBUG}" - } - }, - "safeoutputs": { - "type": "local", - "command": "node", - "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], - "tools": ["*"], - "env": { - "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", - "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", - "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", - "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", - "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", - "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", - "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", - "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", - "GITHUB_SERVER_URL": "\${GITHUB_SERVER_URL}", - "GITHUB_SHA": "\${GITHUB_SHA}", - "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", - "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}" - } - } - } - } - EOF - echo "-------START MCP CONFIG-----------" - cat /home/runner/.copilot/mcp-config.json - echo "-------END MCP CONFIG-----------" - echo "-------/home/runner/.copilot-----------" - find /home/runner/.copilot - echo "HOME: $HOME" - echo "GITHUB_COPILOT_CLI_MODE: $GITHUB_COPILOT_CLI_MODE" - - name: Start MCP Gateway - run: | - mkdir -p /tmp/gh-aw/mcp-gateway-logs - echo 'Starting MCP Gateway...' - - # Development mode: Build awmg from sources - if [ -f "cmd/awmg/main.go" ] && [ -f "Makefile" ]; then - echo 'Building awmg from sources (development mode)...' - make build-awmg - if [ -f "./awmg" ]; then - echo 'Built awmg successfully' - AWMG_CMD="./awmg" - else - echo 'ERROR: Failed to build awmg from sources' - exit 1 - fi - # Check if awmg is already in PATH - elif command -v awmg &> /dev/null; then - echo 'awmg is already available in PATH' - AWMG_CMD="awmg" - # Check for local awmg build - elif [ -f "./awmg" ]; then - echo 'Using existing local awmg build' - AWMG_CMD="./awmg" - else - echo 'ERROR: Could not find awmg binary or source files' - echo 'Please build awmg with: make build-awmg' - exit 1 - fi - - # Start MCP gateway in background with config file - $AWMG_CMD --config /home/runner/.copilot/mcp-config.json --port 8080 --log-dir /tmp/gh-aw/mcp-gateway-logs > /tmp/gh-aw/mcp-gateway-logs/gateway.log 2>&1 & - GATEWAY_PID=$! - echo "MCP Gateway started with PID $GATEWAY_PID" - - # Give the gateway a moment to start - sleep 2 - - name: Verify MCP Gateway Health - run: | - echo 'Waiting for MCP Gateway to be ready...' - - # Show MCP config file content - echo 'MCP Configuration:' - cat /home/runner/.copilot/mcp-config.json || echo 'No MCP config file found' - echo '' - - # Verify safeinputs and safeoutputs are present in config - if ! grep -q '"safeinputs"' /home/runner/.copilot/mcp-config.json; then - echo 'ERROR: safeinputs server not found in MCP configuration' - exit 1 - fi - if ! grep -q '"safeoutputs"' /home/runner/.copilot/mcp-config.json; then - echo 'ERROR: safeoutputs server not found in MCP configuration' - exit 1 - fi - echo 'Verified: safeinputs and safeoutputs are present in configuration' - - max_retries=30 - retry_count=0 - gateway_url="http://localhost:8080" - while [ $retry_count -lt $max_retries ]; do - if curl -s -o /dev/null -w "%{http_code}" "${gateway_url}/health" | grep -q "200\|204"; then - echo "MCP Gateway is ready!" - curl -s "${gateway_url}/servers" || echo "Could not fetch servers list" - - # Test MCP server connectivity through gateway - echo '' - echo 'Testing MCP server connectivity...' - - # Extract first external MCP server name from config (excluding safeinputs/safeoutputs) - mcp_server=$(jq -r '.mcpServers | to_entries[] | select(.key != "safeinputs" and .key != "safeoutputs") | .key' /home/runner/.copilot/mcp-config.json | head -n 1) - if [ -n "$mcp_server" ]; then - echo "Testing connectivity to MCP server: $mcp_server" - mcp_url="${gateway_url}/mcp/${mcp_server}" - echo "MCP URL: $mcp_url" - - # Test with MCP initialize call - response=$(curl -s -w "\n%{http_code}" -X POST "$mcp_url" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}') - - http_code=$(echo "$response" | tail -n 1) - body=$(echo "$response" | head -n -1) - - echo "HTTP Status: $http_code" - echo "Response: $body" - - if [ "$http_code" = "200" ]; then - echo "✓ MCP server connectivity test passed" - else - echo "⚠ MCP server returned HTTP $http_code (may need authentication or different request)" - fi - else - echo "No external MCP servers configured for testing" - fi - - exit 0 - fi - retry_count=$((retry_count + 1)) - echo "Waiting for gateway... (attempt $retry_count/$max_retries)" - sleep 1 - done - echo "Error: MCP Gateway failed to start after $max_retries attempts" - - # Show gateway logs for debugging - echo 'Gateway logs:' - cat /tmp/gh-aw/mcp-gateway-logs/gateway.log || echo 'No gateway logs found' - exit 1 - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.372", - workflow_name: "Dev", - experimental: false, - supports_tools_allowlist: true, - supports_http_transport: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - network_mode: "defaults", - allowed_domains: ["api.github.com","defaults","python"], - firewall_enabled: true, - awf_version: "v0.7.0", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() + } + EOF + echo "-------START MCP CONFIG-----------" + cat /home/runner/.copilot/mcp-config.json + echo "-------END MCP CONFIG-----------" + echo "-------/home/runner/.copilot-----------" + find /home/runner/.copilot + echo "HOME: $HOME" + echo "GITHUB_COPILOT_CLI_MODE: $GITHUB_COPILOT_CLI_MODE" + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.372", + workflow_name: "Dev", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + network_mode: "defaults", + allowed_domains: [], + firewall_enabled: true, + awf_version: "v0.7.0", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() }; // Write to /tmp/gh-aw directory to avoid inclusion in PR @@ -3492,310 +1948,38 @@ jobs: 'Run details\n\n' + '#### Engine Configuration\n' + '| Property | Value |\n' + - '|----------|-------|\n' + - `| Engine ID | ${awInfo.engine_id} |\n` + - `| Engine Name | ${awInfo.engine_name} |\n` + - `| Model | ${awInfo.model || '(default)'} |\n` + - '\n' + - '#### Network Configuration\n' + - '| Property | Value |\n' + - '|----------|-------|\n' + - `| Mode | ${awInfo.network_mode || 'defaults'} |\n` + - `| Firewall | ${awInfo.firewall_enabled ? '✅ Enabled' : '❌ Disabled'} |\n` + - `| Firewall Version | ${awInfo.awf_version || '(latest)'} |\n` + - '\n' + - (networkDetails ? `##### Allowed Domains\n${networkDetails}\n` : '') + - ''; - - await core.summary.addRaw(summary).write(); - console.log('Generated workflow overview in step summary'); - - name: Create prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - run: | - PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" - mkdir -p "$PROMPT_DIR" - cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" - **IMPORTANT**: Always use the `safeinputs-gh` tool for GitHub CLI commands instead of running `gh` directly via bash. The `safeinputs-gh` tool has proper authentication configured with `GITHUB_TOKEN`, while bash commands do not have GitHub CLI authentication by default. - - **Correct**: - ``` - Use the safeinputs-gh tool with args: "pr list --limit 5" - Use the safeinputs-gh tool with args: "issue view 123" - ``` - - **Incorrect**: - ``` - Use the gh safe-input tool with args: "pr list --limit 5" ❌ (Wrong tool name - use safeinputs-gh) - Run: gh pr list --limit 5 ❌ (No authentication in bash) - Execute bash: gh issue view 123 ❌ (No authentication in bash) - ``` - - - - # Python Data Visualization Guide - - Python scientific libraries have been installed and are ready for use. A temporary folder structure has been created at `/tmp/gh-aw/python/` for organizing scripts, data, and outputs. - - ## Installed Libraries - - - **NumPy**: Array processing and numerical operations - - **Pandas**: Data manipulation and analysis - - **Matplotlib**: Chart generation and plotting - - **Seaborn**: Statistical data visualization - - **SciPy**: Scientific computing utilities - - ## Directory Structure - - ``` - /tmp/gh-aw/python/ - ├── data/ # Store all data files here (CSV, JSON, etc.) - ├── charts/ # Generated chart images (PNG) - ├── artifacts/ # Additional output files - └── *.py # Python scripts - ``` - - ## Data Separation Requirement - - **CRITICAL**: Data must NEVER be inlined in Python code. Always store data in external files and load using pandas. - - ### ❌ PROHIBITED - Inline Data - ```python - # DO NOT do this - data = [10, 20, 30, 40, 50] - labels = ['A', 'B', 'C', 'D', 'E'] - ``` - - ### ✅ REQUIRED - External Data Files - ```python - # Always load data from external files - import pandas as pd - - # Load data from CSV - data = pd.read_csv('/tmp/gh-aw/python/data/data.csv') - - # Or from JSON - data = pd.read_json('/tmp/gh-aw/python/data/data.json') - ``` - - ## Chart Generation Best Practices - - ### High-Quality Chart Settings - - ```python - import matplotlib.pyplot as plt - import seaborn as sns - - # Set style for better aesthetics - sns.set_style("whitegrid") - sns.set_palette("husl") - - # Create figure with high DPI - fig, ax = plt.subplots(figsize=(10, 6), dpi=300) - - # Your plotting code here - # ... - - # Save with high quality - plt.savefig('/tmp/gh-aw/python/charts/chart.png', - dpi=300, - bbox_inches='tight', - facecolor='white', - edgecolor='none') - ``` - - ### Chart Quality Guidelines - - - **DPI**: Use 300 or higher for publication quality - - **Figure Size**: Standard is 10x6 inches (adjustable based on needs) - - **Labels**: Always include clear axis labels and titles - - **Legend**: Add legends when plotting multiple series - - **Grid**: Enable grid lines for easier reading - - **Colors**: Use colorblind-friendly palettes (seaborn defaults are good) - - ## Including Images in Reports - - When creating reports (issues, discussions, etc.), use the `upload asset` tool to make images URL-addressable and include them in markdown: - - ### Step 1: Generate and Upload Chart - ```python - # Generate your chart - plt.savefig('/tmp/gh-aw/python/charts/my_chart.png', dpi=300, bbox_inches='tight') - ``` - - ### Step 2: Upload as Asset - Use the `upload asset` tool to upload the chart file. The tool will return a GitHub raw content URL. - - ### Step 3: Include in Markdown Report - When creating your discussion or issue, include the image using markdown: - - ```markdown - ## Visualization Results - - ![Chart Description](https://raw.githubusercontent.com/owner/repo/assets/workflow-name/my_chart.png) - - The chart above shows... - ``` - - **Important**: Assets are published to an orphaned git branch and become URL-addressable after workflow completion. - - ## Cache Memory Integration - - The cache memory at `/tmp/gh-aw/cache-memory/` is available for storing reusable code: - - **Helper Functions to Cache:** - - Data loading utilities: `data_loader.py` - - Chart styling functions: `chart_utils.py` - - Common data transformations: `transforms.py` - - **Check Cache Before Creating:** - ```bash - # Check if helper exists in cache - if [ -f /tmp/gh-aw/cache-memory/data_loader.py ]; then - cp /tmp/gh-aw/cache-memory/data_loader.py /tmp/gh-aw/python/ - echo "Using cached data_loader.py" - fi - ``` - - **Save to Cache for Future Runs:** - ```bash - # Save useful helpers to cache - cp /tmp/gh-aw/python/data_loader.py /tmp/gh-aw/cache-memory/ - echo "Saved data_loader.py to cache for future runs" - ``` - - ## Complete Example Workflow - - ```python - #!/usr/bin/env python3 - """ - Example data visualization script - Generates a bar chart from external data - """ - import pandas as pd - import matplotlib.pyplot as plt - import seaborn as sns - - # Set style - sns.set_style("whitegrid") - sns.set_palette("husl") - - # Load data from external file (NEVER inline) - data = pd.read_csv('/tmp/gh-aw/python/data/data.csv') - - # Process data - summary = data.groupby('category')['value'].sum() - - # Create chart - fig, ax = plt.subplots(figsize=(10, 6), dpi=300) - summary.plot(kind='bar', ax=ax) - - # Customize - ax.set_title('Data Summary by Category', fontsize=16, fontweight='bold') - ax.set_xlabel('Category', fontsize=12) - ax.set_ylabel('Value', fontsize=12) - ax.grid(True, alpha=0.3) - - # Save chart - plt.savefig('/tmp/gh-aw/python/charts/chart.png', - dpi=300, - bbox_inches='tight', - facecolor='white') - - print("Chart saved to /tmp/gh-aw/python/charts/chart.png") - ``` - - ## Error Handling - - **Check File Existence:** - ```python - import os - - data_file = '/tmp/gh-aw/python/data/data.csv' - if not os.path.exists(data_file): - raise FileNotFoundError(f"Data file not found: {data_file}") - ``` - - **Validate Data:** - ```python - # Check for required columns - required_cols = ['category', 'value'] - missing = set(required_cols) - set(data.columns) - if missing: - raise ValueError(f"Missing columns: {missing}") - ``` - - ## Artifact Upload - - Charts and source files are automatically uploaded as artifacts: - - **Charts Artifact:** - - Name: `data-charts` - - Contents: PNG files from `/tmp/gh-aw/python/charts/` - - Retention: 30 days - - **Source and Data Artifact:** - - Name: `python-source-and-data` - - Contents: Python scripts and data files - - Retention: 30 days - - Both artifacts are uploaded with `if: always()` condition, ensuring they're available even if the workflow fails. - - ## Tips for Success - - 1. **Always Separate Data**: Store data in files, never inline in code - 2. **Use Cache Memory**: Store reusable helpers for faster execution - 3. **High Quality Charts**: Use DPI 300+ and proper sizing - 4. **Clear Documentation**: Add docstrings and comments - 5. **Error Handling**: Validate data and check file existence - 6. **Type Hints**: Use type annotations for better code quality - 7. **Seaborn Defaults**: Leverage seaborn for better aesthetics - 8. **Reproducibility**: Set random seeds when needed - - ## Common Data Sources - - Based on common use cases: - - **Repository Statistics:** - ```python - # Collect via GitHub API, save to data.csv - # Then load and visualize - data = pd.read_csv('/tmp/gh-aw/python/data/repo_stats.csv') - ``` - - **Workflow Metrics:** - ```python - # Collect via GitHub Actions API, save to data.json - data = pd.read_json('/tmp/gh-aw/python/data/workflow_metrics.json') - ``` - - **Sample Data Generation:** - ```python - # Generate with NumPy, save to file first - import numpy as np - data = np.random.randn(100, 2) - df = pd.DataFrame(data, columns=['x', 'y']) - df.to_csv('/tmp/gh-aw/python/data/sample_data.csv', index=False) - - # Then load it back (demonstrating the pattern) - data = pd.read_csv('/tmp/gh-aw/python/data/sample_data.csv') - ``` + '|----------|-------|\n' + + `| Engine ID | ${awInfo.engine_id} |\n` + + `| Engine Name | ${awInfo.engine_name} |\n` + + `| Model | ${awInfo.model || '(default)'} |\n` + + '\n' + + '#### Network Configuration\n' + + '| Property | Value |\n' + + '|----------|-------|\n' + + `| Mode | ${awInfo.network_mode || 'defaults'} |\n` + + `| Firewall | ${awInfo.firewall_enabled ? '✅ Enabled' : '❌ Disabled'} |\n` + + `| Firewall Version | ${awInfo.awf_version || '(latest)'} |\n` + + '\n' + + (networkDetails ? `##### Allowed Domains\n${networkDetails}\n` : '') + + ''; + + await core.summary.addRaw(summary).write(); + console.log('Generated workflow overview in step summary'); + - name: Create prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + run: | + PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" + mkdir -p "$PROMPT_DIR" + cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" + # Issue Updater - # Test Upload Asset with Python Graph Generation + Goal: prove we can **update a Project item** that points to a real GitHub Issue. - Create a dummy graph using Python and matplotlib, then upload it as an asset. + Project board: - **Requirements:** - 1. Use Python to create a simple graph (e.g., a sine wave or bar chart) using matplotlib - 2. Save the graph as a PNG file to /tmp/graph.png - 3. Use the `upload_asset` tool to upload the graph - 4. The tool should return a URL where the graph can be accessed - 5. Create an issue that includes the graph using markdown image syntax - 6. Verify that: - - The graph file was created successfully - - The asset was uploaded and a URL was returned - - The issue was created with the embedded graph image + Task: Update all issue items that are currently on the project board with Status "In Progress". PROMPT_EOF - name: Append XPIA security instructions to prompt @@ -3830,31 +2014,6 @@ jobs: When you need to create temporary files or directories during your work, always use the /tmp/gh-aw/agent/ directory that has been pre-created for you. Do NOT use the root /tmp/ directory directly. - PROMPT_EOF - - name: Append cache memory instructions to prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: | - cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" - - --- - - ## Cache Folder Available - - You have access to a persistent cache folder at `/tmp/gh-aw/cache-memory/` where you can read and write files to create memories and store information. - - - **Read/Write Access**: You can freely read from and write to any files in this folder - - **Persistence**: Files in this folder persist across workflow runs via GitHub Actions cache - - **Last Write Wins**: If multiple processes write to the same file, the last write will be preserved - - **File Share**: Use this as a simple file share - organize files as you see fit - - Examples of what you can store: - - `/tmp/gh-aw/cache-memory/notes.txt` - general notes and observations - - `/tmp/gh-aw/cache-memory/preferences.json` - user preferences and settings - - `/tmp/gh-aw/cache-memory/history.log` - activity history and logs - - `/tmp/gh-aw/cache-memory/state/` - organized state files in subdirectories - - Feel free to create, read, update, and organize files in this folder as needed for your tasks. PROMPT_EOF - name: Append safe outputs instructions to prompt env: @@ -3869,7 +2028,7 @@ jobs: To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - **Available tools**: create_issue, missing_tool, noop, upload_asset + **Available tools**: missing_tool, noop, update_project **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. @@ -4155,26 +2314,23 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): - timeout-minutes: 5 + # --allow-tool github + # --allow-tool safeoutputs + timeout-minutes: 20 run: | set -o pipefail - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.7.0 \ - -- /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"} \ + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.7.0 \ + -- /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"} \ 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" - GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" - GH_AW_ASSETS_MAX_SIZE_KB: 10240 - GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_DEBUG: 1 GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_WORKSPACE: ${{ github.workspace }} @@ -4290,11 +2446,12 @@ jobs: } await main(); env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,TEST_ORG_PROJECT_WRITE' SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_TEST_ORG_PROJECT_WRITE: ${{ secrets.TEST_ORG_PROJECT_WRITE }} - name: Upload Safe Outputs if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 @@ -4307,7 +2464,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: @@ -5557,13 +3714,6 @@ jobs: name: mcp-logs path: /tmp/gh-aw/mcp-logs/ if-no-files-found: ignore - - name: Upload SafeInputs logs - if: always() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: safeinputs - path: /tmp/gh-aw/safe-inputs/logs/ - if-no-files-found: ignore - name: Parse agent logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -7207,19 +5357,6 @@ jobs: name: agent-stdio.log path: /tmp/gh-aw/agent-stdio.log if-no-files-found: warn - - name: Upload cache-memory data as artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - if: always() - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Upload safe outputs assets - if: always() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: safe-outputs-assets - path: /tmp/gh-aw/safeoutputs/assets/ - if-no-files-found: ignore - name: Validate agent logs for errors if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -7463,8 +5600,6 @@ jobs: - agent - detection - safe_outputs - - update_cache_memory - - upload_assets if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -8002,7 +6137,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: WORKFLOW_NAME: "Dev" - WORKFLOW_DESCRIPTION: "Test upload-asset with Python graph generation" + WORKFLOW_DESCRIPTION: "Update issues on an org-owned Project Board" with: script: | const fs = require('fs'); @@ -8231,16 +6366,11 @@ jobs: runs-on: ubuntu-slim permissions: contents: read - issues: write timeout-minutes: 15 env: GH_AW_ENGINE_ID: "copilot" GH_AW_WORKFLOW_ID: "dev" GH_AW_WORKFLOW_NAME: "Dev" - outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8258,156 +6388,6 @@ jobs: shell: bash run: | mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' // @ts-check /// @@ -8471,969 +6451,465 @@ jobs: // Check for empty content if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; + core.info("Agent output content is empty"); + return { success: false }; } - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; + core.info(`Agent output content length: ${outputContent.length}`); + + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; } - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } + // Validate items array exists + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); + return { success: true, items: validatedOutput.items }; } - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; + module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + EOF_b93f537f + - name: Update Project + id: update_project + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project')) uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_TITLE_PREFIX: "[Dev Test] " with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} script: | globalThis.github = github; globalThis.context = context; globalThis.core = core; globalThis.exec = exec; globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function main() { - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("temporary_id_map", "{}"); - core.setOutput("issues_to_assign_copilot", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; - } - if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; + function logGraphQLError(error, operation) { + (core.info(`GraphQL Error during: ${operation}`), core.info(`Message: ${error.message}`)); + const errorList = Array.isArray(error.errors) ? error.errors : [], + hasInsufficientScopes = errorList.some(e => e && "INSUFFICIENT_SCOPES" === e.type), + hasNotFound = errorList.some(e => e && "NOT_FOUND" === e.type); + (hasInsufficientScopes + ? core.info( + "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project." + ) + : hasNotFound && + /projectV2\b/.test(error.message) && + core.info( + "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." + ), + error.errors && + (core.info(`Errors array (${error.errors.length} error(s)):`), + error.errors.forEach((err, idx) => { + (core.info(` [${idx + 1}] ${err.message}`), + err.type && core.info(` Type: ${err.type}`), + err.path && core.info(` Path: ${JSON.stringify(err.path)}`), + err.locations && core.info(` Locations: ${JSON.stringify(err.locations)}`)); + })), + error.request && core.info(`Request: ${JSON.stringify(error.request, null, 2)}`), + error.data && core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`)); + } + function parseProjectInput(projectUrl) { + if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); + const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); + if (!urlMatch) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); + return urlMatch[1]; + } + function parseProjectUrl(projectUrl) { + if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); + const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); + if (!match) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); + return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] }; + } + async function listAccessibleProjectsV2(projectInfo) { + const baseQuery = + "projectsV2(first: 100) {\n totalCount\n nodes {\n id\n number\n title\n closed\n url\n }\n edges {\n node {\n id\n number\n title\n closed\n url\n }\n }\n }"; + if ("orgs" === projectInfo.scope) { + const result = await github.graphql(`query($login: String!) {\n organization(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), + conn = result && result.organization && result.organization.projectsV2, + rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], + rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], + nodeNodes = rawNodes.filter(Boolean), + edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), + unique = new Map(); + for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); + return { + nodes: Array.from(unique.values()), + totalCount: conn && conn.totalCount, + diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, + }; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } + const result = await github.graphql(`query($login: String!) {\n user(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), + conn = result && result.user && result.user.projectsV2, + rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], + rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], + nodeNodes = rawNodes.filter(Boolean), + edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), + unique = new Map(); + for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); + return { + nodes: Array.from(unique.values()), + totalCount: conn && conn.totalCount, + diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, + }; + } + function summarizeProjectsV2(projects, limit = 20) { + if (!Array.isArray(projects) || 0 === projects.length) return "(none)"; + const normalized = projects + .filter(p => p && "number" == typeof p.number && "string" == typeof p.title) + .slice(0, limit) + .map(p => `#${p.number} ${p.closed ? "(closed) " : ""}${p.title}`); + return normalized.length > 0 ? normalized.join("; ") : "(none)"; + } + function summarizeEmptyProjectsV2List(list) { + const total = "number" == typeof list.totalCount ? list.totalCount : void 0, + d = list && list.diagnostics, + diag = d ? ` nodes=${d.rawNodesCount} (null=${d.nullNodesCount}), edges=${d.rawEdgesCount} (nullNode=${d.nullEdgeNodesCount})` : ""; + return "number" == typeof total && total > 0 + ? `(none; totalCount=${total} but returned 0 readable project nodes${diag}. This often indicates the token can see the org/user but lacks Projects v2 access, or the org enforces SSO and the token is not authorized.)` + : `(none${diag})`; + } + async function resolveProjectV2(projectInfo, projectNumberInt) { + try { + if ("orgs" === projectInfo.scope) { + const direct = await github.graphql( + "query($login: String!, $number: Int!) {\n organization(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", + { login: projectInfo.ownerLogin, number: projectNumberInt } + ), + project = direct && direct.organization && direct.organization.projectV2; + if (project) return project; } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } + const direct = await github.graphql( + "query($login: String!, $number: Int!) {\n user(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", + { login: projectInfo.ownerLogin, number: projectNumberInt } + ), + project = direct && direct.user && direct.user.projectV2; + if (project) return project; } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } + } catch (error) { + core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${error.message}`); + } + const list = await listAccessibleProjectsV2(projectInfo), + nodes = Array.isArray(list.nodes) ? list.nodes : [], + found = nodes.find(p => p && "number" == typeof p.number && p.number === projectNumberInt); + if (found) return found; + const summary = nodes.length > 0 ? summarizeProjectsV2(nodes) : summarizeEmptyProjectsV2List(list), + total = "number" == typeof list.totalCount ? ` (totalCount=${list.totalCount})` : "", + who = "orgs" === projectInfo.scope ? `org ${projectInfo.ownerLogin}` : `user ${projectInfo.ownerLogin}`; + throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`); + } + function generateCampaignId(projectUrl, projectNumber) { + const urlMatch = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects/); + return `${`${urlMatch ? urlMatch[2] : "project"}-project-${projectNumber}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .substring(0, 30)}-${Date.now().toString(36).substring(0, 8)}`; + } + async function updateProject(output) { + const { owner, repo } = context.repo, + projectInfo = parseProjectUrl(output.project), + projectNumberFromUrl = projectInfo.projectNumber, + campaignId = output.campaign_id; + try { + let repoResult; + (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); + try { + repoResult = await github.graphql( + "query($owner: String!, $repo: String!) {\n repository(owner: $owner, name: $repo) {\n id\n owner {\n id\n __typename\n }\n }\n }", + { owner, repo } + ); + } catch (error) { + throw (logGraphQLError(error, "Fetching repository information"), error); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); + const repositoryId = repoResult.repository.id, + ownerType = repoResult.repository.owner.__typename; + core.info(`✓ Repository: ${owner}/${repo} (${ownerType})`); try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } + const viewerResult = await github.graphql("query {\n viewer {\n login\n }\n }"); + viewerResult && viewerResult.viewer && viewerResult.viewer.login && core.info(`✓ Authenticated as: ${viewerResult.viewer.login}`); + } catch (viewerError) { + core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`); + } + let projectId; + core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); + let resolvedProjectNumber = projectNumberFromUrl; + try { + const projectNumberInt = parseInt(projectNumberFromUrl, 10); + if (!Number.isFinite(projectNumberInt)) throw new Error(`Invalid project number parsed from URL: ${projectNumberFromUrl}`); + const project = await resolveProjectV2(projectInfo, projectNumberInt); + ((projectId = project.id), (resolvedProjectNumber = String(project.number)), core.info(`✓ Resolved project #${resolvedProjectNumber} (${projectInfo.ownerLogin}) (ID: ${projectId})`)); + } catch (error) { + throw (logGraphQLError(error, "Resolving project from URL"), error); + } + core.info("[3/5] Linking project to repository..."); + try { + await github.graphql( + "mutation($projectId: ID!, $repositoryId: ID!) {\n linkProjectV2ToRepository(input: {\n projectId: $projectId,\n repositoryId: $repositoryId\n }) {\n repository {\n id\n }\n }\n }", + { projectId, repositoryId } + ); + } catch (linkError) { + (linkError.message && linkError.message.includes("already linked")) || (logGraphQLError(linkError, "Linking project to repository"), core.warning(`Could not link project: ${linkError.message}`)); + } + (core.info("✓ Project linked to repository"), core.info("[4/5] Processing content (issue/PR/draft) if specified...")); + const hasContentNumber = void 0 !== output.content_number && null !== output.content_number, + hasIssue = void 0 !== output.issue && null !== output.issue, + hasPullRequest = void 0 !== output.pull_request && null !== output.pull_request, + values = []; + if ( + (hasContentNumber && values.push({ key: "content_number", value: output.content_number }), + hasIssue && values.push({ key: "issue", value: output.issue }), + hasPullRequest && values.push({ key: "pull_request", value: output.pull_request }), + values.length > 1) + ) { + const uniqueValues = [...new Set(values.map(v => String(v.value)))], + list = values.map(v => `${v.key}=${v.value}`).join(", "), + descriptor = uniqueValues.length > 1 ? "different values" : `same value "${uniqueValues[0]}"`; + core.warning(`Multiple content number fields (${descriptor}): ${list}. Using priority content_number > issue > pull_request.`); + } + (hasIssue && core.warning('Field "issue" deprecated; use "content_number" instead.'), hasPullRequest && core.warning('Field "pull_request" deprecated; use "content_number" instead.')); + if ("draft_issue" === output.content_type) { + values.length > 0 && core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); + const draftTitle = "string" == typeof output.draft_title ? output.draft_title.trim() : ""; + if (!draftTitle) throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.'); + const draftBody = "string" == typeof output.draft_body ? output.draft_body : void 0; + const itemId = ( + await github.graphql( + "mutation($projectId: ID!, $title: String!, $body: String) {\n addProjectV2DraftIssue(input: {\n projectId: $projectId,\n title: $title,\n body: $body\n }) {\n projectItem {\n id\n }\n }\n }", + { projectId, title: draftTitle, body: draftBody } + ) + ).addProjectV2DraftIssue.projectItem.id; + const fieldsToUpdate = output.fields ? { ...output.fields } : {}; + if (Object.keys(fieldsToUpdate).length > 0) { + const projectFields = ( + await github.graphql( + "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", + { projectId } + ) + ).node.fields.nodes; + for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { + const normalizedFieldName = fieldName + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + let valueToSet, + field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + if (!field) + if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) + try { + field = ( + await github.graphql( + "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", + { projectId, name: normalizedFieldName, dataType: "TEXT" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } + else + try { + field = ( + await github.graphql( + "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", + { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; } + if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) }; + else if (field.options) { + let option = field.options.find(o => o.name === fieldValue); + if (!option) + try { + const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], + updatedField = ( + await github.graphql( + "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", + { fieldId: field.id, fieldName: field.name, options: allOptions } + ) + ).updateProjectV2Field.projectV2Field; + ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); + } catch (createError) { + core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); + continue; + } + if (!option) { + core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); + continue; } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); + valueToSet = { singleSelectOptionId: option.id }; + } else valueToSet = { text: String(fieldValue) }; + await github.graphql( + "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", + { projectId, itemId, fieldId: field.id, value: valueToSet } + ); + } + } + core.setOutput("item-id", itemId); + return; + } + let contentNumber = null; + if (hasContentNumber || hasIssue || hasPullRequest) { + const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request, + sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); + if (sanitizedContentNumber) { + if (!/^\d+$/.test(sanitizedContentNumber)) throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`); + contentNumber = Number.parseInt(sanitizedContentNumber, 10); + } else core.warning("Content number field provided but empty; skipping project item update."); + } + if (null !== contentNumber) { + const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", + contentQuery = + "Issue" === contentType + ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }" + : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }", + contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }), + contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest, + contentId = contentData.id, + createdAt = contentData.createdAt, + closedAt = contentData.closedAt, + existingItem = await (async function (projectId, contentId) { + let hasNextPage = !0, + endCursor = null; + for (; hasNextPage; ) { + const result = await github.graphql( + "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", + { projectId, after: endCursor } + ), + found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); + if (found) return found; + ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); + } + return null; + })(projectId, contentId); + let itemId; + if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); + else { + itemId = ( + await github.graphql( + "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", + { projectId, contentId } + ) + ).addProjectV2ItemById.item.id; + if (campaignId) { try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); + await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); + } catch (labelError) { + core.warning(`Failed to add campaign label: ${labelError.message}`); } } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + const fieldsToUpdate = output.fields ? { ...output.fields } : {}; + if (Object.keys(fieldsToUpdate).length > 0) { + const projectFields = ( + await github.graphql( + "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", + { projectId } + ) + ).node.fields.nodes; + for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { + const normalizedFieldName = fieldName + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + let valueToSet, + field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); + if (!field) + if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) + try { + field = ( + await github.graphql( + "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", + { projectId, name: normalizedFieldName, dataType: "TEXT" } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; + } + else + try { + field = ( + await github.graphql( + "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", + { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } + ) + ).createProjectV2Field.projectV2Field; + } catch (createError) { + core.warning(`Failed to create field "${fieldName}": ${createError.message}`); + continue; + } + if (field.dataType === "DATE") { + valueToSet = { date: String(fieldValue) }; + } else if (field.options) { + let option = field.options.find(o => o.name === fieldValue); + if (!option) + try { + const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], + updatedField = ( + await github.graphql( + "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", + { fieldId: field.id, fieldName: field.name, options: allOptions } + ) + ).updateProjectV2Field.projectV2Field; + ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); + } catch (createError) { + core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); + continue; + } + if (!option) { + core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); + continue; + } + valueToSet = { singleSelectOptionId: option.id }; + } else valueToSet = { text: String(fieldValue) }; + await github.graphql( + "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", + { projectId, itemId, fieldId: field.id, value: valueToSet } + ); + } } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + core.setOutput("item-id", itemId); } - await core.summary.addRaw(summaryContent).write(); - } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - update_cache_memory: - needs: - - agent - - detection - if: always() && needs.detection.outputs.success == 'true' - runs-on: ubuntu-latest - permissions: {} - steps: - - name: Download cache-memory artifact (default) - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - continue-on-error: true - with: - name: cache-memory - path: /tmp/gh-aw/cache-memory - - name: Save cache-memory to cache (default) - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - key: memory-${{ github.workflow }}-${{ github.run_id }} - path: /tmp/gh-aw/cache-memory - - upload_assets: - needs: - - agent - - detection - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - runs-on: ubuntu-slim - permissions: - contents: write - timeout-minutes: 10 - outputs: - branch_name: ${{ steps.upload_assets.outputs.branch_name }} - published_count: ${{ steps.upload_assets.outputs.published_count }} - steps: - - name: Checkout repository - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - persist-credentials: false - fetch-depth: 0 - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Download assets - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: safe-outputs-assets - path: /tmp/gh-aw/safeoutputs/assets/ - - name: List downloaded asset files - continue-on-error: true - run: | - echo "Downloaded asset files:" - ls -la /tmp/gh-aw/safeoutputs/assets/ - - name: Download agent output artifact - continue-on-error: true - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 - with: - name: agent_output.json - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Upload Assets to Orphaned Branch - id: upload_assets - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" - GH_AW_ASSETS_MAX_SIZE_KB: 10240 - GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" - GH_AW_WORKFLOW_NAME: "Dev" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const MAX_LOG_CONTENT_LENGTH = 10000; - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - return { success: true, items: validatedOutput.items }; - } - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); + if (error.message && error.message.includes("does not have permission to create projects")) { + const usingCustomToken = !!process.env.GH_AW_PROJECT_GITHUB_TOKEN; + core.error( + `Failed to manage project: ${error.message}\n\nTroubleshooting:\n • Create the project manually at https://github.com/orgs/${owner}/projects/new.\n • Or supply a PAT (classic with project + repo scopes, or fine-grained with Projects: Read+Write) via GH_AW_PROJECT_GITHUB_TOKEN.\n • Or use a GitHub App with Projects: Read+Write permission.\n • Ensure the workflow grants projects: write.\n\n` + + (usingCustomToken ? "GH_AW_PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN - this cannot access Projects v2 API. You must configure GH_AW_PROJECT_GITHUB_TOKEN.") + ); + } else core.error(`Failed to manage project: ${error.message}`); + throw error; } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; } async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_asset"); - if (uploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${uploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; - try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadItems) { + if (!result.success) return; + const updateProjectItems = result.items.filter(item => "update_project" === item.type); + if (0 !== updateProjectItems.length) + for (let i = 0; i < updateProjectItems.length; i++) { + const output = updateProjectItems[i]; try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); + await updateProject(output); } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } + (core.error(`Failed to process item ${i + 1}`), logGraphQLError(error, `Processing update_project item ${i + 1}`)); } - core.summary.write(); - } else { - core.info("No new assets to upload"); } - } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; - } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); } - await main(); + ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main()); diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index b5efd5d9f0d..215628a0078 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -1,48 +1,28 @@ --- -on: - workflow_dispatch: name: Dev -description: Test upload-asset with Python graph generation -timeout-minutes: 5 -strict: true -engine: copilot +description: Update issues on an org-owned Project Board +on: + workflow_dispatch: permissions: contents: read issues: read - -sandbox: - mcp: - port: 8080 + pull-requests: read tools: github: - toolsets: [issues] + toolsets: [default, projects] + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} # fine-grained PAT with scopes Organizational: `Projects: Read & Write` and `Metadata: Read` and `Issues: Read & Write` safe-outputs: - upload-asset: - allowed-exts: [".png", ".jpg"] - max: 5 - create-issue: - title-prefix: "[Dev Test] " - max: 1 - -imports: - - shared/gh.md - - shared/python-dataviz.md + update-project: + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} --- -# Test Upload Asset with Python Graph Generation +# Issue Updater + +Goal: prove we can **update a Project item** that points to a real GitHub Issue. -Create a dummy graph using Python and matplotlib, then upload it as an asset. +Project board: -**Requirements:** -1. Use Python to create a simple graph (e.g., a sine wave or bar chart) using matplotlib -2. Save the graph as a PNG file to /tmp/graph.png -3. Use the `upload_asset` tool to upload the graph -4. The tool should return a URL where the graph can be accessed -5. Create an issue that includes the graph using markdown image syntax -6. Verify that: - - The graph file was created successfully - - The asset was uploaded and a URL was returned - - The issue was created with the embedded graph image +Task: Update all issue items that are currently on the project board with Status "In Progress". From 5d03a4a669c9080fd46361dcb06ac7ce3e941b13 Mon Sep 17 00:00:00 2001 From: Mara Nikola Kiefer Date: Tue, 23 Dec 2025 18:36:49 +0100 Subject: [PATCH 2/2] update --- .github/workflows/dev.lock.yml | 3828 ++++++++++++++--- .github/workflows/dev.md | 46 +- ...ayground-org-project-update-issue.lock.yml | 70 +- .../playground-org-project-update-issue.md | 8 +- 4 files changed, 3220 insertions(+), 732 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 0e53327f1d8..a4b014146de 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -19,7 +19,12 @@ # gh aw compile # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # -# Update issues on an org-owned Project Board +# Test upload-asset with Python graph generation +# +# Resolved workflow manifest: +# Imports: +# - shared/gh.md +# - shared/python-dataviz.md name: "Dev" "on": @@ -133,10 +138,12 @@ jobs: permissions: contents: read issues: read - pull-requests: read concurrency: group: "gh-aw-copilot-${{ github.workflow }}" env: + GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" + GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" + GH_AW_ASSETS_MAX_SIZE_KB: 10240 GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs GH_AW_SAFE_OUTPUTS: /tmp/gh-aw/safeoutputs/outputs.jsonl GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /tmp/gh-aw/safeoutputs/config.json @@ -156,6 +163,44 @@ jobs: mkdir -p /tmp/gh-aw/agent mkdir -p /tmp/gh-aw/sandbox/agent/logs echo "Created /tmp/gh-aw/agent directory for agentic workflow temporary files" + - name: Setup Python environment + run: "# Create working directory for Python scripts\nmkdir -p /tmp/gh-aw/python\nmkdir -p /tmp/gh-aw/python/data\nmkdir -p /tmp/gh-aw/python/charts\nmkdir -p /tmp/gh-aw/python/artifacts\n\necho \"Python environment setup complete\"\necho \"Working directory: /tmp/gh-aw/python\"\necho \"Data directory: /tmp/gh-aw/python/data\"\necho \"Charts directory: /tmp/gh-aw/python/charts\"\necho \"Artifacts directory: /tmp/gh-aw/python/artifacts\"\n" + - name: Install Python scientific libraries + run: "pip install --user --quiet numpy pandas matplotlib seaborn scipy\n\n# Verify installations\npython3 -c \"import numpy; print(f'NumPy {numpy.__version__} installed')\"\npython3 -c \"import pandas; print(f'Pandas {pandas.__version__} installed')\"\npython3 -c \"import matplotlib; print(f'Matplotlib {matplotlib.__version__} installed')\"\npython3 -c \"import seaborn; print(f'Seaborn {seaborn.__version__} installed')\"\npython3 -c \"import scipy; print(f'SciPy {scipy.__version__} installed')\"\n\necho \"All scientific libraries installed successfully\"\n" + - if: always() + name: Upload generated charts + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + if-no-files-found: warn + name: data-charts + path: /tmp/gh-aw/python/charts/*.png + retention-days: 30 + - if: always() + name: Upload source files and data + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + if-no-files-found: warn + name: python-source-and-data + path: | + /tmp/gh-aw/python/*.py + /tmp/gh-aw/python/data/* + retention-days: 30 + + # Cache memory file share configuration from frontmatter processed below + - name: Create cache-memory directory + run: | + mkdir -p /tmp/gh-aw/cache-memory + echo "Cache memory directory created at /tmp/gh-aw/cache-memory" + echo "This folder provides persistent file storage across workflow runs" + echo "LLMs and agentic tools can freely read and write files in this directory" + - name: Restore cache memory file share data + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ github.workflow }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + restore-keys: | + memory-${{ github.workflow }}- + memory- - name: Configure Git credentials env: REPO_NAME: ${{ github.repository }} @@ -285,134 +330,147 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs cat > /tmp/gh-aw/safeoutputs/config.json << 'EOF' - {"missing_tool":{"max":0},"noop":{"max":1},"update_project":{"max":10}} + {"create_issue":{"max":1},"missing_tool":{"max":0},"noop":{"max":1},"upload_asset":{"max":5}} EOF cat > /tmp/gh-aw/safeoutputs/tools.json << 'EOF' [ { - "description": "Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[Dev Test] \".", "inputSchema": { "additionalProperties": false, "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", "type": "string" }, - "reason": { - "description": "Explanation of why this tool is needed to complete the task (max 256 characters).", + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. Can be a real issue number (e.g., 42) or a temporary_id (e.g., 'aw_abc123def456') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 12 hex characters (e.g., 'aw_abc123def456'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", "type": "string" }, - "tool": { - "description": "Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", "type": "string" } }, "required": [ - "tool", - "reason" + "title", + "body" ], "type": "object" }, - "name": "missing_tool" + "name": "create_issue" }, { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "description": "Upload a file as a URL-addressable asset that can be referenced in issues, PRs, or comments. The file is stored on an orphaned git branch and returns a permanent URL. Use this for images, diagrams, or other files that need to be embedded in GitHub content. CONSTRAINTS: Maximum 5 asset(s) can be uploaded. Maximum file size: 10240KB. Allowed file extensions: [.png .jpg].", "inputSchema": { "additionalProperties": false, "properties": { - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "path": { + "description": "Absolute file path to upload (e.g., '/tmp/chart.png'). Must be under the workspace or /tmp directory. By default, only image files (.png, .jpg, .jpeg) are allowed; other file types require workflow configuration.", "type": "string" } }, "required": [ - "message" + "path" ], "type": "object" }, - "name": "noop" + "name": "upload_asset" }, { - "description": "Add or update items in GitHub Projects v2 boards. Can add issues/PRs to a project and update custom field values. Requires the project URL, content type (issue or pull_request), and content number. Use campaign_id to group related items.", + "description": "Report that a tool or capability needed to complete the task is not available. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", "inputSchema": { "additionalProperties": false, - "oneOf": [ - { - "properties": { - "content_type": { - "enum": [ - "issue", - "pull_request" - ] - } - }, - "required": [ - "project", - "content_type", - "content_number" - ] - }, - { - "properties": { - "content_type": { - "const": "draft_issue" - } - }, - "required": [ - "project", - "content_type", - "draft_title" - ] - } - ], "properties": { - "campaign_id": { - "description": "Campaign identifier to group related project items. Used to track items created by the same campaign or workflow run.", - "type": "string" - }, - "content_number": { - "description": "Issue or pull request number to add to the project (e.g., 123 for issue #123). Required when content_type is 'issue' or 'pull_request'.", - "type": "number" - }, - "content_type": { - "description": "Type of item to add to the project. Use 'issue' or 'pull_request' to add existing repo content, or 'draft_issue' to create a draft item inside the project.", - "enum": [ - "issue", - "pull_request", - "draft_issue" - ], + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", "type": "string" }, - "create_if_missing": { - "description": "Whether to create the project if it doesn't exist. Defaults to false. Requires projects:write permission when true.", - "type": "boolean" - }, - "draft_body": { - "description": "Optional body for a Projects v2 draft issue (markdown). Only used when content_type is 'draft_issue'.", + "reason": { + "description": "Explanation of why this tool is needed to complete the task (max 256 characters).", "type": "string" }, - "draft_title": { - "description": "Title for a Projects v2 draft issue. Required when content_type is 'draft_issue'.", + "tool": { + "description": "Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", "type": "string" - }, - "fields": { - "description": "Custom field values to set on the project item (e.g., {'Status': 'In Progress', 'Priority': 'High'}). Field names must match custom fields defined in the project.", - "type": "object" - }, - "project": { - "description": "Full GitHub project URL (e.g., 'https://github.com/orgs/myorg/projects/42' or 'https://github.com/users/username/projects/5'). Project names or numbers alone are NOT accepted.", - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+$", + } + }, + "required": [ + "tool", + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", "type": "string" } }, + "required": [ + "message" + ], "type": "object" }, - "name": "update_project" + "name": "noop" } ] EOF cat > /tmp/gh-aw/safeoutputs/validation.json << 'EOF' { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, "missing_tool": { "defaultMax": 20, "fields": { @@ -446,40 +504,12 @@ jobs: } } }, - "update_project": { + "upload_asset": { "defaultMax": 10, "fields": { - "campaign_id": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "content_number": { - "optionalPositiveInteger": true - }, - "content_type": { - "type": "string", - "enum": [ - "issue", - "pull_request" - ] - }, - "fields": { - "type": "object" - }, - "issue": { - "optionalPositiveInteger": true - }, - "project": { + "path": { "required": true, - "type": "string", - "sanitize": true, - "maxLength": 512, - "pattern": "^https://github\\.com/(orgs|users)/[^/]+/projects/\\d+", - "patternError": "must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/42)" - }, - "pull_request": { - "optionalPositiveInteger": true + "type": "string" } } } @@ -1822,125 +1852,1639 @@ jobs: EOF chmod +x /tmp/gh-aw/safeoutputs/mcp-server.cjs - - name: Setup MCPs - env: - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + - name: Setup Safe Inputs JavaScript and Config run: | - mkdir -p /tmp/gh-aw/mcp-config - mkdir -p /home/runner/.copilot - cat > /home/runner/.copilot/mcp-config.json << EOF - { - "mcpServers": { - "github": { - "type": "local", - "command": "docker", - "args": [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "-e", - "GITHUB_READ_ONLY=1", - "-e", - "GITHUB_TOOLSETS=context,repos,issues,pull_requests,projects", - "ghcr.io/github/github-mcp-server:v0.26.3" - ], - "tools": ["*"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}" + mkdir -p /tmp/gh-aw/safe-inputs/logs + cat > /tmp/gh-aw/safe-inputs/read_buffer.cjs << 'EOF_READ_BUFFER' + class ReadBuffer { + constructor() { + this._buffer = null; + } + append(chunk) { + this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + } + readMessage() { + if (!this._buffer) { + return null; } - }, - "safeoutputs": { - "type": "local", - "command": "node", - "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], - "tools": ["*"], - "env": { - "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", - "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", - "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", - "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", - "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", - "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", - "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", - "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", - "GITHUB_SERVER_URL": "\${GITHUB_SERVER_URL}", - "GITHUB_SHA": "\${GITHUB_SHA}", - "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", - "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}" + const index = this._buffer.indexOf("\n"); + if (index === -1) { + return null; + } + const line = this._buffer.toString("utf8", 0, index).replace(/\r$/, ""); + this._buffer = this._buffer.subarray(index + 1); + if (line.trim() === "") { + return this.readMessage(); + } + try { + return JSON.parse(line); + } catch (error) { + throw new Error(`Parse error: ${error instanceof Error ? error.message : String(error)}`); } } } - } - EOF - echo "-------START MCP CONFIG-----------" - cat /home/runner/.copilot/mcp-config.json - echo "-------END MCP CONFIG-----------" - echo "-------/home/runner/.copilot-----------" - find /home/runner/.copilot - echo "HOME: $HOME" - echo "GITHUB_COPILOT_CLI_MODE: $GITHUB_COPILOT_CLI_MODE" - - name: Generate agentic run info - id: generate_aw_info - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - - const awInfo = { - engine_id: "copilot", - engine_name: "GitHub Copilot CLI", - model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", - version: "", - agent_version: "0.0.372", - workflow_name: "Dev", - experimental: false, - supports_tools_allowlist: true, - supports_http_transport: true, - run_id: context.runId, - run_number: context.runNumber, - run_attempt: process.env.GITHUB_RUN_ATTEMPT, - repository: context.repo.owner + '/' + context.repo.repo, - ref: context.ref, - sha: context.sha, - actor: context.actor, - event_name: context.eventName, - staged: false, - network_mode: "defaults", - allowed_domains: [], - firewall_enabled: true, - awf_version: "v0.7.0", - steps: { - firewall: "squid" - }, - created_at: new Date().toISOString() + module.exports = { + ReadBuffer, }; - - // Write to /tmp/gh-aw directory to avoid inclusion in PR - const tmpPath = '/tmp/gh-aw/aw_info.json'; - fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); - console.log('Generated aw_info.json at:', tmpPath); - console.log(JSON.stringify(awInfo, null, 2)); - - // Set model as output for reuse in other steps/jobs - core.setOutput('model', awInfo.model); - - name: Generate workflow overview - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const fs = require('fs'); - const awInfoPath = '/tmp/gh-aw/aw_info.json'; - - // Load aw_info.json - const awInfo = JSON.parse(fs.readFileSync(awInfoPath, 'utf8')); - - let networkDetails = ''; - if (awInfo.allowed_domains && awInfo.allowed_domains.length > 0) { - networkDetails = awInfo.allowed_domains.slice(0, 10).map(d => ` - ${d}`).join('\n'); - if (awInfo.allowed_domains.length > 10) { - networkDetails += `\n - ... and ${awInfo.allowed_domains.length - 10} more`; + EOF_READ_BUFFER + cat > /tmp/gh-aw/safe-inputs/mcp_server_core.cjs << 'EOF_MCP_CORE' + const fs = require("fs"); + const path = require("path"); + const { ReadBuffer } = require("./read_buffer.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + const encoder = new TextEncoder(); + function initLogFile(server) { + if (server.logFileInitialized || !server.logDir || !server.logFilePath) return; + try { + if (!fs.existsSync(server.logDir)) { + fs.mkdirSync(server.logDir, { recursive: true }); + } + const timestamp = new Date().toISOString(); + fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`); + server.logFileInitialized = true; + } catch { + } + } + function createDebugFunction(server) { + return msg => { + const timestamp = new Date().toISOString(); + const formattedMsg = `[${timestamp}] [${server.serverInfo.name}] ${msg}\n`; + process.stderr.write(formattedMsg); + if (server.logDir && server.logFilePath) { + if (!server.logFileInitialized) { + initLogFile(server); + } + if (server.logFileInitialized) { + try { + fs.appendFileSync(server.logFilePath, formattedMsg); + } catch { + } + } + } + }; + } + function createDebugErrorFunction(server) { + return (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + server.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + server.debug(`${prefix}Stack trace: ${error.stack}`); + } + }; + } + function createWriteMessageFunction(server) { + return obj => { + const json = JSON.stringify(obj); + server.debug(`send: ${json}`); + const message = json + "\n"; + const bytes = encoder.encode(message); + fs.writeSync(1, bytes); + }; + } + function createReplyResultFunction(server) { + return (id, result) => { + if (id === undefined || id === null) return; + const res = { jsonrpc: "2.0", id, result }; + server.writeMessage(res); + }; + } + function createReplyErrorFunction(server) { + return (id, code, message) => { + if (id === undefined || id === null) { + server.debug(`Error for notification: ${message}`); + return; + } + const error = { code, message }; + const res = { + jsonrpc: "2.0", + id, + error, + }; + server.writeMessage(res); + }; + } + function createServer(serverInfo, options = {}) { + const logDir = options.logDir || undefined; + const logFilePath = logDir ? path.join(logDir, "server.log") : undefined; + const server = { + serverInfo, + tools: {}, + debug: () => {}, + debugError: () => {}, + writeMessage: () => {}, + replyResult: () => {}, + replyError: () => {}, + readBuffer: new ReadBuffer(), + logDir, + logFilePath, + logFileInitialized: false, + }; + server.debug = createDebugFunction(server); + server.debugError = createDebugErrorFunction(server); + server.writeMessage = createWriteMessageFunction(server); + server.replyResult = createReplyResultFunction(server); + server.replyError = createReplyErrorFunction(server); + return server; + } + function createWrappedHandler(server, toolName, handlerFn) { + return async args => { + server.debug(` [${toolName}] Invoking handler with args: ${JSON.stringify(args)}`); + try { + const result = await Promise.resolve(handlerFn(args)); + server.debug(` [${toolName}] Handler returned result type: ${typeof result}`); + if (result && typeof result === "object" && Array.isArray(result.content)) { + server.debug(` [${toolName}] Result is already in MCP format`); + return result; + } + let serializedResult; + try { + serializedResult = JSON.stringify(result); + } catch (serializationError) { + server.debugError(` [${toolName}] Serialization error: `, serializationError); + serializedResult = String(result); + } + server.debug(` [${toolName}] Serialized result: ${serializedResult.substring(0, 200)}${serializedResult.length > 200 ? "..." : ""}`); + return { + content: [ + { + type: "text", + text: serializedResult, + }, + ], + }; + } catch (error) { + server.debugError(` [${toolName}] Handler threw error: `, error); + throw error; + } + }; + } + function loadToolHandlers(server, tools, basePath) { + server.debug(`Loading tool handlers...`); + server.debug(` Total tools to process: ${tools.length}`); + server.debug(` Base path: ${basePath || "(not specified)"}`); + let loadedCount = 0; + let skippedCount = 0; + let errorCount = 0; + for (const tool of tools) { + const toolName = tool.name || "(unnamed)"; + if (!tool.handler) { + server.debug(` [${toolName}] No handler path specified, skipping handler load`); + skippedCount++; + continue; + } + const handlerPath = tool.handler; + server.debug(` [${toolName}] Handler path specified: ${handlerPath}`); + let resolvedPath = handlerPath; + if (basePath && !path.isAbsolute(handlerPath)) { + resolvedPath = path.resolve(basePath, handlerPath); + server.debug(` [${toolName}] Resolved relative path to: ${resolvedPath}`); + const normalizedBase = path.resolve(basePath); + const normalizedResolved = path.resolve(resolvedPath); + if (!normalizedResolved.startsWith(normalizedBase + path.sep) && normalizedResolved !== normalizedBase) { + server.debug(` [${toolName}] ERROR: Handler path escapes base directory: ${resolvedPath} is not within ${basePath}`); + errorCount++; + continue; + } + } else if (path.isAbsolute(handlerPath)) { + server.debug(` [${toolName}] Using absolute path (bypasses basePath validation): ${handlerPath}`); + } + tool.handlerPath = handlerPath; + try { + server.debug(` [${toolName}] Loading handler from: ${resolvedPath}`); + if (!fs.existsSync(resolvedPath)) { + server.debug(` [${toolName}] ERROR: Handler file does not exist: ${resolvedPath}`); + errorCount++; + continue; + } + const ext = path.extname(resolvedPath).toLowerCase(); + server.debug(` [${toolName}] Handler file extension: ${ext}`); + if (ext === ".sh") { + server.debug(` [${toolName}] Detected shell script handler`); + try { + fs.accessSync(resolvedPath, fs.constants.X_OK); + server.debug(` [${toolName}] Shell script is executable`); + } catch { + try { + fs.chmodSync(resolvedPath, 0o755); + server.debug(` [${toolName}] Made shell script executable`); + } catch (chmodError) { + server.debugError(` [${toolName}] Warning: Could not make shell script executable: `, chmodError); + } + } + const { createShellHandler } = require("./mcp_handler_shell.cjs"); + const timeout = tool.timeout || 60; + tool.handler = createShellHandler(server, toolName, resolvedPath, timeout); + loadedCount++; + server.debug(` [${toolName}] Shell handler created successfully with timeout: ${timeout}s`); + } else if (ext === ".py") { + server.debug(` [${toolName}] Detected Python script handler`); + try { + fs.accessSync(resolvedPath, fs.constants.X_OK); + server.debug(` [${toolName}] Python script is executable`); + } catch { + try { + fs.chmodSync(resolvedPath, 0o755); + server.debug(` [${toolName}] Made Python script executable`); + } catch (chmodError) { + server.debugError(` [${toolName}] Warning: Could not make Python script executable: `, chmodError); + } + } + const { createPythonHandler } = require("./mcp_handler_python.cjs"); + const timeout = tool.timeout || 60; + tool.handler = createPythonHandler(server, toolName, resolvedPath, timeout); + loadedCount++; + server.debug(` [${toolName}] Python handler created successfully with timeout: ${timeout}s`); + } else { + server.debug(` [${toolName}] Loading JavaScript handler module`); + const handlerModule = require(resolvedPath); + server.debug(` [${toolName}] Handler module loaded successfully`); + server.debug(` [${toolName}] Module type: ${typeof handlerModule}`); + let handlerFn = handlerModule; + if (handlerModule && typeof handlerModule === "object" && typeof handlerModule.default === "function") { + handlerFn = handlerModule.default; + server.debug(` [${toolName}] Using module.default export`); + } + if (typeof handlerFn !== "function") { + server.debug(` [${toolName}] ERROR: Handler is not a function, got: ${typeof handlerFn}`); + server.debug(` [${toolName}] Module keys: ${Object.keys(handlerModule || {}).join(", ") || "(none)"}`); + errorCount++; + continue; + } + server.debug(` [${toolName}] Handler function validated successfully`); + server.debug(` [${toolName}] Handler function name: ${handlerFn.name || "(anonymous)"}`); + tool.handler = createWrappedHandler(server, toolName, handlerFn); + loadedCount++; + server.debug(` [${toolName}] JavaScript handler loaded and wrapped successfully`); + } + } catch (error) { + server.debugError(` [${toolName}] ERROR loading handler: `, error); + errorCount++; + } + } + server.debug(`Handler loading complete:`); + server.debug(` Loaded: ${loadedCount}`); + server.debug(` Skipped (no handler path): ${skippedCount}`); + server.debug(` Errors: ${errorCount}`); + return tools; + } + function registerTool(server, tool) { + const normalizedName = normalizeTool(tool.name); + server.tools[normalizedName] = { + ...tool, + name: normalizedName, + }; + server.debug(`Registered tool: ${normalizedName}`); + } + function normalizeTool(name) { + return name.replace(/-/g, "_").toLowerCase(); + } + async function handleRequest(server, request, defaultHandler) { + const { id, method, params } = request; + try { + if (!("id" in request)) { + return null; + } + let result; + if (method === "initialize") { + const protocolVersion = params?.protocolVersion || "2024-11-05"; + result = { + protocolVersion, + serverInfo: server.serverInfo, + capabilities: { + tools: {}, + }, + }; + } else if (method === "ping") { + result = {}; + } else if (method === "tools/list") { + const list = []; + Object.values(server.tools).forEach(tool => { + const toolDef = { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }; + list.push(toolDef); + }); + result = { tools: list }; + } else if (method === "tools/call") { + const name = params?.name; + const args = params?.arguments ?? {}; + if (!name || typeof name !== "string") { + throw { + code: -32602, + message: "Invalid params: 'name' must be a string", + }; + } + const tool = server.tools[normalizeTool(name)]; + if (!tool) { + throw { + code: -32602, + message: `Tool '${name}' not found`, + }; + } + let handler = tool.handler; + if (!handler && defaultHandler) { + handler = defaultHandler(tool.name); + } + if (!handler) { + throw { + code: -32603, + message: `No handler for tool: ${name}`, + }; + } + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw { + code: -32602, + message: `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`, + }; + } + const handlerResult = await Promise.resolve(handler(args)); + const content = handlerResult && handlerResult.content ? handlerResult.content : []; + result = { content, isError: false }; + } else if (/^notifications\//.test(method)) { + return null; + } else { + throw { + code: -32601, + message: `Method not found: ${method}`, + }; + } + return { + jsonrpc: "2.0", + id, + result, + }; + } catch (error) { + const err = error; + return { + jsonrpc: "2.0", + id, + error: { + code: err.code || -32603, + message: err.message || "Internal error", + }, + }; + } + } + async function handleMessage(server, req, defaultHandler) { + if (!req || typeof req !== "object") { + server.debug(`Invalid message: not an object`); + return; + } + if (req.jsonrpc !== "2.0") { + server.debug(`Invalid message: missing or invalid jsonrpc field`); + return; + } + const { id, method, params } = req; + if (!method || typeof method !== "string") { + server.replyError(id, -32600, "Invalid Request: method must be a string"); + return; + } + try { + if (method === "initialize") { + const clientInfo = params?.clientInfo ?? {}; + server.debug(`client info: ${JSON.stringify(clientInfo)}`); + const protocolVersion = params?.protocolVersion ?? undefined; + const result = { + serverInfo: server.serverInfo, + ...(protocolVersion ? { protocolVersion } : {}), + capabilities: { + tools: {}, + }, + }; + server.replyResult(id, result); + } else if (method === "tools/list") { + const list = []; + Object.values(server.tools).forEach(tool => { + const toolDef = { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }; + list.push(toolDef); + }); + server.replyResult(id, { tools: list }); + } else if (method === "tools/call") { + const name = params?.name; + const args = params?.arguments ?? {}; + if (!name || typeof name !== "string") { + server.replyError(id, -32602, "Invalid params: 'name' must be a string"); + return; + } + const tool = server.tools[normalizeTool(name)]; + if (!tool) { + server.replyError(id, -32601, `Tool not found: ${name} (${normalizeTool(name)})`); + return; + } + let handler = tool.handler; + if (!handler && defaultHandler) { + handler = defaultHandler(tool.name); + } + if (!handler) { + server.replyError(id, -32603, `No handler for tool: ${name}`); + return; + } + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + server.replyError(id, -32602, `Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + return; + } + server.debug(`Calling handler for tool: ${name}`); + const result = await Promise.resolve(handler(args)); + server.debug(`Handler returned for tool: ${name}`); + const content = result && result.content ? result.content : []; + server.replyResult(id, { content, isError: false }); + } else if (/^notifications\//.test(method)) { + server.debug(`ignore ${method}`); + } else { + server.replyError(id, -32601, `Method not found: ${method}`); + } + } catch (e) { + server.replyError(id, -32603, e instanceof Error ? e.message : String(e)); + } + } + async function processReadBuffer(server, defaultHandler) { + while (true) { + try { + const message = server.readBuffer.readMessage(); + if (!message) { + break; + } + server.debug(`recv: ${JSON.stringify(message)}`); + await handleMessage(server, message, defaultHandler); + } catch (error) { + server.debug(`Parse error: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + function start(server, options = {}) { + const { defaultHandler } = options; + server.debug(`v${server.serverInfo.version} ready on stdio`); + server.debug(` tools: ${Object.keys(server.tools).join(", ")}`); + if (!Object.keys(server.tools).length) { + throw new Error("No tools registered"); + } + const onData = async chunk => { + server.readBuffer.append(chunk); + await processReadBuffer(server, defaultHandler); + }; + process.stdin.on("data", onData); + process.stdin.on("error", err => server.debug(`stdin error: ${err}`)); + process.stdin.resume(); + server.debug(`listening...`); + } + module.exports = { + createServer, + registerTool, + normalizeTool, + handleRequest, + handleMessage, + processReadBuffer, + start, + loadToolHandlers, + }; + EOF_MCP_CORE + cat > /tmp/gh-aw/safe-inputs/mcp_http_transport.cjs << 'EOF_MCP_HTTP_TRANSPORT' + const http = require("http"); + const { randomUUID } = require("crypto"); + const { createServer, registerTool, handleRequest } = require("./mcp_server_core.cjs"); + class MCPServer { + constructor(serverInfo, options = {}) { + this._coreServer = createServer(serverInfo, options); + this.serverInfo = serverInfo; + this.capabilities = options.capabilities || { tools: {} }; + this.tools = new Map(); + this.transport = null; + this.initialized = false; + } + tool(name, description, inputSchema, handler) { + this.tools.set(name, { + name, + description, + inputSchema, + handler, + }); + registerTool(this._coreServer, { + name, + description, + inputSchema, + handler, + }); + } + async connect(transport) { + this.transport = transport; + transport.setServer(this); + await transport.start(); + } + async handleRequest(request) { + if (request.method === "initialize") { + this.initialized = true; + } + return handleRequest(this._coreServer, request); + } + } + class MCPHTTPTransport { + constructor(options = {}) { + this.sessionIdGenerator = options.sessionIdGenerator; + this.enableJsonResponse = options.enableJsonResponse !== false; + this.enableDnsRebindingProtection = options.enableDnsRebindingProtection || false; + this.server = null; + this.sessionId = null; + this.started = false; + } + setServer(server) { + this.server = server; + } + async start() { + if (this.started) { + throw new Error("Transport already started"); + } + this.started = true; + } + async handleRequest(req, res, parsedBody) { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = parsedBody; + if (!body) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + if (!body) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32600, + message: "Invalid Request: Empty request body", + }, + id: null, + }) + ); + return; + } + if (!body.jsonrpc || body.jsonrpc !== "2.0") { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32600, + message: "Invalid Request: jsonrpc must be '2.0'", + }, + id: body.id || null, + }) + ); + return; + } + if (this.sessionIdGenerator) { + if (body.method === "initialize") { + this.sessionId = this.sessionIdGenerator(); + } else { + const requestSessionId = req.headers["mcp-session-id"]; + if (!requestSessionId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32600, + message: "Invalid Request: Missing Mcp-Session-Id header", + }, + id: body.id || null, + }) + ); + return; + } + if (requestSessionId !== this.sessionId) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Session not found", + }, + id: body.id || null, + }) + ); + return; + } + } + } + const response = await this.server.handleRequest(body); + if (response === null) { + res.writeHead(204); + res.end(); + return; + } + const headers = { "Content-Type": "application/json" }; + if (this.sessionId) { + headers["mcp-session-id"] = this.sessionId; + } + res.writeHead(200, headers); + res.end(JSON.stringify(response)); + } catch (error) { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + } + } + module.exports = { + MCPServer, + MCPHTTPTransport, + }; + EOF_MCP_HTTP_TRANSPORT + cat > /tmp/gh-aw/safe-inputs/mcp_logger.cjs << 'EOF_MCP_LOGGER' + function createLogger(serverName) { + const logger = { + debug: msg => { + const timestamp = new Date().toISOString(); + process.stderr.write(`[${timestamp}] [${serverName}] ${msg}\n`); + }, + debugError: (prefix, error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug(`${prefix}${errorMessage}`); + if (error instanceof Error && error.stack) { + logger.debug(`${prefix}Stack trace: ${error.stack}`); + } + }, + }; + return logger; + } + module.exports = { + createLogger, + }; + EOF_MCP_LOGGER + cat > /tmp/gh-aw/safe-inputs/mcp_handler_shell.cjs << 'EOF_HANDLER_SHELL' + const fs = require("fs"); + const path = require("path"); + const { execFile } = require("child_process"); + const os = require("os"); + function createShellHandler(server, toolName, scriptPath, timeoutSeconds = 60) { + return async args => { + server.debug(` [${toolName}] Invoking shell handler: ${scriptPath}`); + server.debug(` [${toolName}] Shell handler args: ${JSON.stringify(args)}`); + server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`); + const env = { ...process.env }; + for (const [key, value] of Object.entries(args || {})) { + const envKey = `INPUT_${key.toUpperCase().replace(/-/g, "_")}`; + env[envKey] = String(value); + server.debug(` [${toolName}] Set env: ${envKey}=${String(value).substring(0, 100)}${String(value).length > 100 ? "..." : ""}`); + } + const outputFile = path.join(os.tmpdir(), `mcp-shell-output-${Date.now()}-${Math.random().toString(36).substring(2)}.txt`); + env.GITHUB_OUTPUT = outputFile; + server.debug(` [${toolName}] Output file: ${outputFile}`); + fs.writeFileSync(outputFile, ""); + return new Promise((resolve, reject) => { + server.debug(` [${toolName}] Executing shell script...`); + execFile( + scriptPath, + [], + { + env, + timeout: timeoutSeconds * 1000, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (stdout) { + server.debug(` [${toolName}] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`); + } + if (stderr) { + server.debug(` [${toolName}] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`); + } + if (error) { + server.debugError(` [${toolName}] Shell script error: `, error); + try { + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + } catch { + } + reject(error); + return; + } + const outputs = {}; + try { + if (fs.existsSync(outputFile)) { + const outputContent = fs.readFileSync(outputFile, "utf-8"); + server.debug(` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`); + const lines = outputContent.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && trimmed.includes("=")) { + const eqIndex = trimmed.indexOf("="); + const key = trimmed.substring(0, eqIndex); + const value = trimmed.substring(eqIndex + 1); + outputs[key] = value; + server.debug(` [${toolName}] Parsed output: ${key}=${value.substring(0, 100)}${value.length > 100 ? "..." : ""}`); + } + } + } + } catch (readError) { + server.debugError(` [${toolName}] Error reading output file: `, readError); + } + try { + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + } catch { + } + const result = { + stdout: stdout || "", + stderr: stderr || "", + outputs, + }; + server.debug(` [${toolName}] Shell handler completed, outputs: ${Object.keys(outputs).join(", ") || "(none)"}`); + resolve({ + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }); + } + ); + }); + }; + } + module.exports = { + createShellHandler, + }; + EOF_HANDLER_SHELL + cat > /tmp/gh-aw/safe-inputs/mcp_handler_python.cjs << 'EOF_HANDLER_PYTHON' + const { execFile } = require("child_process"); + function createPythonHandler(server, toolName, scriptPath, timeoutSeconds = 60) { + return async args => { + server.debug(` [${toolName}] Invoking Python handler: ${scriptPath}`); + server.debug(` [${toolName}] Python handler args: ${JSON.stringify(args)}`); + server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`); + const inputJson = JSON.stringify(args || {}); + server.debug(` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`); + return new Promise((resolve, reject) => { + server.debug(` [${toolName}] Executing Python script...`); + const child = execFile( + "python3", + [scriptPath], + { + env: process.env, + timeout: timeoutSeconds * 1000, + maxBuffer: 10 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (stdout) { + server.debug(` [${toolName}] stdout: ${stdout.substring(0, 500)}${stdout.length > 500 ? "..." : ""}`); + } + if (stderr) { + server.debug(` [${toolName}] stderr: ${stderr.substring(0, 500)}${stderr.length > 500 ? "..." : ""}`); + } + if (error) { + server.debugError(` [${toolName}] Python script error: `, error); + reject(error); + return; + } + let result; + try { + if (stdout && stdout.trim()) { + result = JSON.parse(stdout.trim()); + } else { + result = { stdout: stdout || "", stderr: stderr || "" }; + } + } catch (parseError) { + server.debug(` [${toolName}] Output is not JSON, returning as text`); + result = { stdout: stdout || "", stderr: stderr || "" }; + } + server.debug(` [${toolName}] Python handler completed successfully`); + resolve({ + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }); + } + ); + if (child.stdin) { + child.stdin.write(inputJson); + child.stdin.end(); + } + }); + }; + } + module.exports = { + createPythonHandler, + }; + EOF_HANDLER_PYTHON + cat > /tmp/gh-aw/safe-inputs/safe_inputs_config_loader.cjs << 'EOF_CONFIG_LOADER' + const fs = require("fs"); + function loadConfig(configPath) { + if (!fs.existsSync(configPath)) { + throw new Error(`Configuration file not found: ${configPath}`); + } + const configContent = fs.readFileSync(configPath, "utf-8"); + const config = JSON.parse(configContent); + if (!config.tools || !Array.isArray(config.tools)) { + throw new Error("Configuration must contain a 'tools' array"); + } + return config; + } + module.exports = { + loadConfig, + }; + EOF_CONFIG_LOADER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_tool_factory.cjs << 'EOF_TOOL_FACTORY' + function createToolConfig(name, description, inputSchema, handlerPath) { + return { + name, + description, + inputSchema, + handler: handlerPath, + }; + } + module.exports = { + createToolConfig, + }; + EOF_TOOL_FACTORY + cat > /tmp/gh-aw/safe-inputs/safe_inputs_validation.cjs << 'EOF_VALIDATION' + function validateRequiredFields(args, inputSchema) { + const requiredFields = inputSchema && Array.isArray(inputSchema.required) ? inputSchema.required : []; + if (!requiredFields.length) { + return []; + } + const missing = requiredFields.filter(f => { + const value = args[f]; + return value === undefined || value === null || (typeof value === "string" && value.trim() === ""); + }); + return missing; + } + module.exports = { + validateRequiredFields, + }; + EOF_VALIDATION + cat > /tmp/gh-aw/safe-inputs/safe_inputs_bootstrap.cjs << 'EOF_BOOTSTRAP' + const path = require("path"); + const fs = require("fs"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { loadToolHandlers } = require("./mcp_server_core.cjs"); + function bootstrapSafeInputsServer(configPath, logger) { + logger.debug(`Loading safe-inputs configuration from: ${configPath}`); + const config = loadConfig(configPath); + const basePath = path.dirname(configPath); + logger.debug(`Base path for handlers: ${basePath}`); + logger.debug(`Tools to load: ${config.tools.length}`); + const tools = loadToolHandlers(logger, config.tools, basePath); + return { config, basePath, tools }; + } + function cleanupConfigFile(configPath, logger) { + try { + if (fs.existsSync(configPath)) { + fs.unlinkSync(configPath); + logger.debug(`Deleted configuration file: ${configPath}`); + } + } catch (error) { + logger.debugError(`Warning: Could not delete configuration file: `, error); + } + } + module.exports = { + bootstrapSafeInputsServer, + cleanupConfigFile, + }; + EOF_BOOTSTRAP + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server.cjs << 'EOF_SAFE_INPUTS_SERVER' + const { createServer, registerTool, start } = require("./mcp_server_core.cjs"); + const { loadConfig } = require("./safe_inputs_config_loader.cjs"); + const { createToolConfig } = require("./safe_inputs_tool_factory.cjs"); + const { bootstrapSafeInputsServer, cleanupConfigFile } = require("./safe_inputs_bootstrap.cjs"); + function startSafeInputsServer(configPath, options = {}) { + const logDir = options.logDir || undefined; + const server = createServer({ name: "safeinputs", version: "1.0.0" }, { logDir }); + const { config, tools } = bootstrapSafeInputsServer(configPath, server); + server.serverInfo.name = config.serverName || "safeinputs"; + server.serverInfo.version = config.version || "1.0.0"; + if (!options.logDir && config.logDir) { + server.logDir = config.logDir; + } + for (const tool of tools) { + registerTool(server, tool); + } + if (!options.skipCleanup) { + cleanupConfigFile(configPath, server); + } + start(server); + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server.cjs [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = {}; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + try { + startSafeInputsServer(configPath, options); + } catch (error) { + console.error(`Error starting safe-inputs server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } + module.exports = { + startSafeInputsServer, + loadConfig, + createToolConfig, + }; + EOF_SAFE_INPUTS_SERVER + cat > /tmp/gh-aw/safe-inputs/safe_inputs_mcp_server_http.cjs << 'EOF_SAFE_INPUTS_SERVER_HTTP' + const http = require("http"); + const { randomUUID } = require("crypto"); + const { MCPServer, MCPHTTPTransport } = require("./mcp_http_transport.cjs"); + const { validateRequiredFields } = require("./safe_inputs_validation.cjs"); + const { createLogger } = require("./mcp_logger.cjs"); + const { bootstrapSafeInputsServer, cleanupConfigFile } = require("./safe_inputs_bootstrap.cjs"); + function createMCPServer(configPath, options = {}) { + const logger = createLogger("safeinputs"); + logger.debug(`=== Creating MCP Server ===`); + logger.debug(`Configuration file: ${configPath}`); + const { config, tools } = bootstrapSafeInputsServer(configPath, logger); + const serverName = config.serverName || "safeinputs"; + const version = config.version || "1.0.0"; + logger.debug(`Server name: ${serverName}`); + logger.debug(`Server version: ${version}`); + const server = new MCPServer( + { + name: serverName, + version: version, + }, + { + capabilities: { + tools: {}, + }, + } + ); + logger.debug(`Registering tools with MCP server...`); + let registeredCount = 0; + let skippedCount = 0; + for (const tool of tools) { + if (!tool.handler) { + logger.debug(`Skipping tool ${tool.name} - no handler loaded`); + skippedCount++; + continue; + } + logger.debug(`Registering tool: ${tool.name}`); + server.tool(tool.name, tool.description || "", tool.inputSchema || { type: "object", properties: {} }, async args => { + logger.debug(`Calling handler for tool: ${tool.name}`); + const missing = validateRequiredFields(args, tool.inputSchema); + if (missing.length) { + throw new Error(`Invalid arguments: missing or empty ${missing.map(m => `'${m}'`).join(", ")}`); + } + const result = await Promise.resolve(tool.handler(args)); + logger.debug(`Handler returned for tool: ${tool.name}`); + const content = result && result.content ? result.content : []; + return { content, isError: false }; + }); + registeredCount++; + } + logger.debug(`Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped`); + logger.debug(`=== MCP Server Creation Complete ===`); + cleanupConfigFile(configPath, logger); + return { server, config, logger }; + } + async function startHttpServer(configPath, options = {}) { + const port = options.port || 3000; + const stateless = options.stateless || false; + const logger = createLogger("safe-inputs-startup"); + logger.debug(`=== Starting Safe Inputs MCP HTTP Server ===`); + logger.debug(`Configuration file: ${configPath}`); + logger.debug(`Port: ${port}`); + logger.debug(`Mode: ${stateless ? "stateless" : "stateful"}`); + logger.debug(`Environment: NODE_VERSION=${process.version}, PLATFORM=${process.platform}`); + try { + const { server, config, logger: mcpLogger } = createMCPServer(configPath, { logDir: options.logDir }); + Object.assign(logger, mcpLogger); + logger.debug(`MCP server created successfully`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools configured: ${config.tools.length}`); + logger.debug(`Creating HTTP transport...`); + const transport = new MCPHTTPTransport({ + sessionIdGenerator: stateless ? undefined : () => randomUUID(), + enableJsonResponse: true, + enableDnsRebindingProtection: false, + }); + logger.debug(`HTTP transport created`); + logger.debug(`Connecting server to transport...`); + await server.connect(transport); + logger.debug(`Server connected to transport successfully`); + logger.debug(`Creating HTTP server...`); + const httpServer = http.createServer(async (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept"); + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "ok", + server: config.serverName || "safeinputs", + version: config.version || "1.0.0", + tools: config.tools.length, + }) + ); + return; + } + if (req.method !== "POST") { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + try { + let body = null; + if (req.method === "POST") { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const bodyStr = Buffer.concat(chunks).toString(); + try { + body = bodyStr ? JSON.parse(bodyStr) : null; + } catch (parseError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: Invalid JSON in request body", + }, + id: null, + }) + ); + return; + } + } + await transport.handleRequest(req, res, body); + } catch (error) { + logger.debugError("Error handling request: ", error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error), + }, + id: null, + }) + ); + } + } + }); + logger.debug(`Attempting to bind to port ${port}...`); + httpServer.listen(port, () => { + logger.debug(`=== Safe Inputs MCP HTTP Server Started Successfully ===`); + logger.debug(`HTTP server listening on http://localhost:${port}`); + logger.debug(`MCP endpoint: POST http://localhost:${port}/`); + logger.debug(`Server name: ${config.serverName || "safeinputs"}`); + logger.debug(`Server version: ${config.version || "1.0.0"}`); + logger.debug(`Tools available: ${config.tools.length}`); + logger.debug(`Server is ready to accept requests`); + }); + httpServer.on("error", error => { + if (error.code === "EADDRINUSE") { + logger.debugError(`ERROR: Port ${port} is already in use. `, error); + } else if (error.code === "EACCES") { + logger.debugError(`ERROR: Permission denied to bind to port ${port}. `, error); + } else { + logger.debugError(`ERROR: Failed to start HTTP server: `, error); + } + process.exit(1); + }); + process.on("SIGINT", () => { + logger.debug("Received SIGINT, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + process.on("SIGTERM", () => { + logger.debug("Received SIGTERM, shutting down..."); + httpServer.close(() => { + logger.debug("HTTP server closed"); + process.exit(0); + }); + }); + return httpServer; + } catch (error) { + const errorLogger = createLogger("safe-inputs-startup-error"); + errorLogger.debug(`=== FATAL ERROR: Failed to start Safe Inputs MCP HTTP Server ===`); + errorLogger.debug(`Error type: ${error.constructor.name}`); + errorLogger.debug(`Error message: ${error.message}`); + if (error.stack) { + errorLogger.debug(`Stack trace:\n${error.stack}`); + } + if (error.code) { + errorLogger.debug(`Error code: ${error.code}`); + } + errorLogger.debug(`Configuration file: ${configPath}`); + errorLogger.debug(`Port: ${port}`); + throw error; + } + } + if (require.main === module) { + const args = process.argv.slice(2); + if (args.length < 1) { + console.error("Usage: node safe_inputs_mcp_server_http.cjs [--port ] [--stateless] [--log-dir ]"); + process.exit(1); + } + const configPath = args[0]; + const options = { + port: 3000, + stateless: false, + logDir: undefined, + }; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--port" && args[i + 1]) { + options.port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--stateless") { + options.stateless = true; + } else if (args[i] === "--log-dir" && args[i + 1]) { + options.logDir = args[i + 1]; + i++; + } + } + startHttpServer(configPath, options).catch(error => { + console.error(`Error starting HTTP server: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); + } + module.exports = { + startHttpServer, + createMCPServer, + }; + EOF_SAFE_INPUTS_SERVER_HTTP + cat > /tmp/gh-aw/safe-inputs/tools.json << 'EOF_TOOLS_JSON' + { + "serverName": "safeinputs", + "version": "1.0.0", + "logDir": "/tmp/gh-aw/safe-inputs/logs", + "tools": [ + { + "name": "gh", + "description": "Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh \u003cargs\u003e. Use single quotes ' for complex args to avoid shell interpretation issues.", + "inputSchema": { + "properties": { + "args": { + "description": "Arguments to pass to gh CLI (without the 'gh' prefix). Examples: 'pr list --limit 5', 'issue view 123', 'api repos/{owner}/{repo}'", + "type": "string" + } + }, + "required": [ + "args" + ], + "type": "object" + }, + "handler": "gh.sh", + "env": { + "GH_AW_GH_TOKEN": "GH_AW_GH_TOKEN", + "GH_DEBUG": "GH_DEBUG" + }, + "timeout": 60 + } + ] + } + EOF_TOOLS_JSON + cat > /tmp/gh-aw/safe-inputs/mcp-server.cjs << 'EOFSI' + const path = require("path"); + const { startHttpServer } = require("./safe_inputs_mcp_server_http.cjs"); + const configPath = path.join(__dirname, "tools.json"); + const port = parseInt(process.env.GH_AW_SAFE_INPUTS_PORT || "3000", 10); + const apiKey = process.env.GH_AW_SAFE_INPUTS_API_KEY || ""; + startHttpServer(configPath, { + port: port, + stateless: false, + logDir: "/tmp/gh-aw/safe-inputs/logs" + }).catch(error => { + console.error("Failed to start safe-inputs HTTP server:", error); + process.exit(1); + }); + EOFSI + chmod +x /tmp/gh-aw/safe-inputs/mcp-server.cjs + + - name: Setup Safe Inputs Tool Files + run: | + cat > /tmp/gh-aw/safe-inputs/gh.sh << 'EOFSH_gh' + #!/bin/bash + # Auto-generated safe-input tool: gh + # Execute any gh CLI command. This tool is accessible as 'safeinputs-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues. + + set -euo pipefail + + echo "gh $INPUT_ARGS" + echo " token: ${GH_AW_GH_TOKEN:0:6}..." + GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS + + EOFSH_gh + chmod +x /tmp/gh-aw/safe-inputs/gh.sh + + - name: Generate Safe Inputs MCP Server Config + id: safe-inputs-config + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + function generateSafeInputsConfig({ core, crypto }) { + const apiKeyBuffer = crypto.randomBytes(45); + const apiKey = apiKeyBuffer.toString("base64").replace(/[/+=]/g, ""); + const port = 3000; + core.setOutput("safe_inputs_api_key", apiKey); + core.setOutput("safe_inputs_port", port.toString()); + core.info(`Safe Inputs MCP server will run on port ${port}`); + return { apiKey, port }; + } + + // Execute the function + const crypto = require('crypto'); + generateSafeInputsConfig({ core, crypto }); + + - name: Start Safe Inputs MCP HTTP Server + id: safe-inputs-start + run: | + # Set environment variables for the server + export GH_AW_SAFE_INPUTS_PORT=${{ steps.safe-inputs-config.outputs.safe_inputs_port }} + export GH_AW_SAFE_INPUTS_API_KEY=${{ steps.safe-inputs-config.outputs.safe_inputs_api_key }} + + export GH_AW_GH_TOKEN="${GH_AW_GH_TOKEN}" + export GH_DEBUG="${GH_DEBUG}" + + cd /tmp/gh-aw/safe-inputs + # Verify required files exist + echo "Verifying safe-inputs setup..." + if [ ! -f mcp-server.cjs ]; then + echo "ERROR: mcp-server.cjs not found in /tmp/gh-aw/safe-inputs" + ls -la /tmp/gh-aw/safe-inputs/ + exit 1 + fi + if [ ! -f tools.json ]; then + echo "ERROR: tools.json not found in /tmp/gh-aw/safe-inputs" + ls -la /tmp/gh-aw/safe-inputs/ + exit 1 + fi + echo "Configuration files verified" + # Log environment configuration + echo "Server configuration:" + echo " Port: $GH_AW_SAFE_INPUTS_PORT" + echo " API Key: ${GH_AW_SAFE_INPUTS_API_KEY:0:8}..." + echo " Working directory: $(pwd)" + # Ensure logs directory exists + mkdir -p /tmp/gh-aw/safe-inputs/logs + # Create initial server.log file for artifact upload + { + echo "Safe Inputs MCP Server Log" + echo "Start time: $(date)" + echo "===========================================" + echo "" + } > /tmp/gh-aw/safe-inputs/logs/server.log + # Start the HTTP server in the background + echo "Starting safe-inputs MCP HTTP server..." + node mcp-server.cjs >> /tmp/gh-aw/safe-inputs/logs/server.log 2>&1 & + SERVER_PID=$! + echo "Started safe-inputs MCP server with PID $SERVER_PID" + # Wait for server to be ready (max 10 seconds) + echo "Waiting for server to become ready..." + for i in {1..10}; do + # Check if process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process $SERVER_PID has died" + echo "Server log contents:" + cat /tmp/gh-aw/safe-inputs/logs/server.log + exit 1 + fi + # Check if server is responding + if curl -s -f "http://localhost:$GH_AW_SAFE_INPUTS_PORT/health" > /dev/null 2>&1; then + echo "Safe Inputs MCP server is ready (attempt $i/10)" + break + fi + if [ "$i" -eq 10 ]; then + echo "ERROR: Safe Inputs MCP server failed to start after 10 seconds" + echo "Process status: $(pgrep -f 'mcp-server.cjs' || echo 'not running')" + echo "Server log contents:" + cat /tmp/gh-aw/safe-inputs/logs/server.log + echo "Checking port availability:" + netstat -tuln | grep "$GH_AW_SAFE_INPUTS_PORT" || echo "Port $GH_AW_SAFE_INPUTS_PORT not listening" + exit 1 + fi + echo "Waiting for server... (attempt $i/10)" + sleep 1 + done + # Output the configuration for the MCP client + echo "port=$GH_AW_SAFE_INPUTS_PORT" >> "$GITHUB_OUTPUT" + echo "api_key=$GH_AW_SAFE_INPUTS_API_KEY" >> "$GITHUB_OUTPUT" + + - name: Setup MCPs + env: + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ASSETS_BRANCH: ${{ env.GH_AW_ASSETS_BRANCH }} + GH_AW_ASSETS_MAX_SIZE_KB: ${{ env.GH_AW_ASSETS_MAX_SIZE_KB }} + GH_AW_ASSETS_ALLOWED_EXTS: ${{ env.GH_AW_ASSETS_ALLOWED_EXTS }} + GH_AW_SAFE_INPUTS_PORT: ${{ steps.safe-inputs-start.outputs.port }} + GH_AW_SAFE_INPUTS_API_KEY: ${{ steps.safe-inputs-start.outputs.api_key }} + GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_DEBUG: 1 + run: | + mkdir -p /tmp/gh-aw/mcp-config + mkdir -p /home/runner/.copilot + cat > /home/runner/.copilot/mcp-config.json << EOF + { + "mcpServers": { + "github": { + "type": "local", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "-e", + "GITHUB_READ_ONLY=1", + "-e", + "GITHUB_TOOLSETS=issues", + "ghcr.io/github/github-mcp-server:v0.26.3" + ], + "tools": ["*"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}" + } + }, + "safeinputs": { + "type": "http", + "url": "http://host.docker.internal:\${GH_AW_SAFE_INPUTS_PORT}", + "headers": { + "Authorization": "Bearer \${GH_AW_SAFE_INPUTS_API_KEY}" + }, + "tools": ["*"], + "env": { + "GH_AW_SAFE_INPUTS_PORT": "\${GH_AW_SAFE_INPUTS_PORT}", + "GH_AW_SAFE_INPUTS_API_KEY": "\${GH_AW_SAFE_INPUTS_API_KEY}", + "GH_AW_GH_TOKEN": "\${GH_AW_GH_TOKEN}", + "GH_DEBUG": "\${GH_DEBUG}" + } + }, + "safeoutputs": { + "type": "local", + "command": "node", + "args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"], + "tools": ["*"], + "env": { + "GH_AW_MCP_LOG_DIR": "\${GH_AW_MCP_LOG_DIR}", + "GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}", + "GH_AW_SAFE_OUTPUTS_CONFIG_PATH": "\${GH_AW_SAFE_OUTPUTS_CONFIG_PATH}", + "GH_AW_SAFE_OUTPUTS_TOOLS_PATH": "\${GH_AW_SAFE_OUTPUTS_TOOLS_PATH}", + "GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}", + "GH_AW_ASSETS_MAX_SIZE_KB": "\${GH_AW_ASSETS_MAX_SIZE_KB}", + "GH_AW_ASSETS_ALLOWED_EXTS": "\${GH_AW_ASSETS_ALLOWED_EXTS}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}", + "GITHUB_SERVER_URL": "\${GITHUB_SERVER_URL}", + "GITHUB_SHA": "\${GITHUB_SHA}", + "GITHUB_WORKSPACE": "\${GITHUB_WORKSPACE}", + "DEFAULT_BRANCH": "\${DEFAULT_BRANCH}" + } + } + } + } + EOF + echo "-------START MCP CONFIG-----------" + cat /home/runner/.copilot/mcp-config.json + echo "-------END MCP CONFIG-----------" + echo "-------/home/runner/.copilot-----------" + find /home/runner/.copilot + echo "HOME: $HOME" + echo "GITHUB_COPILOT_CLI_MODE: $GITHUB_COPILOT_CLI_MODE" + - name: Start MCP Gateway + run: | + mkdir -p /tmp/gh-aw/mcp-gateway-logs + echo 'Starting MCP Gateway...' + + # Development mode: Build awmg from sources + if [ -f "cmd/awmg/main.go" ] && [ -f "Makefile" ]; then + echo 'Building awmg from sources (development mode)...' + make build-awmg + if [ -f "./awmg" ]; then + echo 'Built awmg successfully' + AWMG_CMD="./awmg" + else + echo 'ERROR: Failed to build awmg from sources' + exit 1 + fi + # Check if awmg is already in PATH + elif command -v awmg &> /dev/null; then + echo 'awmg is already available in PATH' + AWMG_CMD="awmg" + # Check for local awmg build + elif [ -f "./awmg" ]; then + echo 'Using existing local awmg build' + AWMG_CMD="./awmg" + else + echo 'ERROR: Could not find awmg binary or source files' + echo 'Please build awmg with: make build-awmg' + exit 1 + fi + + # Start MCP gateway in background with config file + $AWMG_CMD --config /home/runner/.copilot/mcp-config.json --port 8080 --log-dir /tmp/gh-aw/mcp-gateway-logs > /tmp/gh-aw/mcp-gateway-logs/gateway.log 2>&1 & + GATEWAY_PID=$! + echo "MCP Gateway started with PID $GATEWAY_PID" + + # Give the gateway a moment to start + sleep 2 + - name: Verify MCP Gateway Health + run: | + echo 'Waiting for MCP Gateway to be ready...' + + # Show MCP config file content + echo 'MCP Configuration:' + cat /home/runner/.copilot/mcp-config.json || echo 'No MCP config file found' + echo '' + + # Verify safeinputs and safeoutputs are present in config + if ! grep -q '"safeinputs"' /home/runner/.copilot/mcp-config.json; then + echo 'ERROR: safeinputs server not found in MCP configuration' + exit 1 + fi + if ! grep -q '"safeoutputs"' /home/runner/.copilot/mcp-config.json; then + echo 'ERROR: safeoutputs server not found in MCP configuration' + exit 1 + fi + echo 'Verified: safeinputs and safeoutputs are present in configuration' + + max_retries=30 + retry_count=0 + gateway_url="http://localhost:8080" + while [ $retry_count -lt $max_retries ]; do + if curl -s -o /dev/null -w "%{http_code}" "${gateway_url}/health" | grep -q "200\|204"; then + echo "MCP Gateway is ready!" + curl -s "${gateway_url}/servers" || echo "Could not fetch servers list" + + # Test MCP server connectivity through gateway + echo '' + echo 'Testing MCP server connectivity...' + + # Extract first external MCP server name from config (excluding safeinputs/safeoutputs) + mcp_server=$(jq -r '.mcpServers | to_entries[] | select(.key != "safeinputs" and .key != "safeoutputs") | .key' /home/runner/.copilot/mcp-config.json | head -n 1) + if [ -n "$mcp_server" ]; then + echo "Testing connectivity to MCP server: $mcp_server" + mcp_url="${gateway_url}/mcp/${mcp_server}" + echo "MCP URL: $mcp_url" + + # Test with MCP initialize call + response=$(curl -s -w "\n%{http_code}" -X POST "$mcp_url" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}') + + http_code=$(echo "$response" | tail -n 1) + body=$(echo "$response" | head -n -1) + + echo "HTTP Status: $http_code" + echo "Response: $body" + + if [ "$http_code" = "200" ]; then + echo "✓ MCP server connectivity test passed" + else + echo "⚠ MCP server returned HTTP $http_code (may need authentication or different request)" + fi + else + echo "No external MCP servers configured for testing" + fi + + exit 0 + fi + retry_count=$((retry_count + 1)) + echo "Waiting for gateway... (attempt $retry_count/$max_retries)" + sleep 1 + done + echo "Error: MCP Gateway failed to start after $max_retries attempts" + + # Show gateway logs for debugging + echo 'Gateway logs:' + cat /tmp/gh-aw/mcp-gateway-logs/gateway.log || echo 'No gateway logs found' + exit 1 + - name: Generate agentic run info + id: generate_aw_info + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('fs'); + + const awInfo = { + engine_id: "copilot", + engine_name: "GitHub Copilot CLI", + model: process.env.GH_AW_MODEL_AGENT_COPILOT || "", + version: "", + agent_version: "0.0.372", + workflow_name: "Dev", + experimental: false, + supports_tools_allowlist: true, + supports_http_transport: true, + run_id: context.runId, + run_number: context.runNumber, + run_attempt: process.env.GITHUB_RUN_ATTEMPT, + repository: context.repo.owner + '/' + context.repo.repo, + ref: context.ref, + sha: context.sha, + actor: context.actor, + event_name: context.eventName, + staged: false, + network_mode: "defaults", + allowed_domains: ["api.github.com","defaults","python"], + firewall_enabled: true, + awf_version: "v0.7.0", + steps: { + firewall: "squid" + }, + created_at: new Date().toISOString() + }; + + // Write to /tmp/gh-aw directory to avoid inclusion in PR + const tmpPath = '/tmp/gh-aw/aw_info.json'; + fs.writeFileSync(tmpPath, JSON.stringify(awInfo, null, 2)); + console.log('Generated aw_info.json at:', tmpPath); + console.log(JSON.stringify(awInfo, null, 2)); + + // Set model as output for reuse in other steps/jobs + core.setOutput('model', awInfo.model); + - name: Generate workflow overview + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('fs'); + const awInfoPath = '/tmp/gh-aw/aw_info.json'; + + // Load aw_info.json + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, 'utf8')); + + let networkDetails = ''; + if (awInfo.allowed_domains && awInfo.allowed_domains.length > 0) { + networkDetails = awInfo.allowed_domains.slice(0, 10).map(d => ` - ${d}`).join('\n'); + if (awInfo.allowed_domains.length > 10) { + networkDetails += `\n - ... and ${awInfo.allowed_domains.length - 10} more`; } } @@ -1973,13 +3517,285 @@ jobs: PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" mkdir -p "$PROMPT_DIR" cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" - # Issue Updater + **IMPORTANT**: Always use the `safeinputs-gh` tool for GitHub CLI commands instead of running `gh` directly via bash. The `safeinputs-gh` tool has proper authentication configured with `GITHUB_TOKEN`, while bash commands do not have GitHub CLI authentication by default. + + **Correct**: + ``` + Use the safeinputs-gh tool with args: "pr list --limit 5" + Use the safeinputs-gh tool with args: "issue view 123" + ``` + + **Incorrect**: + ``` + Use the gh safe-input tool with args: "pr list --limit 5" ❌ (Wrong tool name - use safeinputs-gh) + Run: gh pr list --limit 5 ❌ (No authentication in bash) + Execute bash: gh issue view 123 ❌ (No authentication in bash) + ``` + + + + # Python Data Visualization Guide + + Python scientific libraries have been installed and are ready for use. A temporary folder structure has been created at `/tmp/gh-aw/python/` for organizing scripts, data, and outputs. + + ## Installed Libraries + + - **NumPy**: Array processing and numerical operations + - **Pandas**: Data manipulation and analysis + - **Matplotlib**: Chart generation and plotting + - **Seaborn**: Statistical data visualization + - **SciPy**: Scientific computing utilities + + ## Directory Structure + + ``` + /tmp/gh-aw/python/ + ├── data/ # Store all data files here (CSV, JSON, etc.) + ├── charts/ # Generated chart images (PNG) + ├── artifacts/ # Additional output files + └── *.py # Python scripts + ``` + + ## Data Separation Requirement + + **CRITICAL**: Data must NEVER be inlined in Python code. Always store data in external files and load using pandas. + + ### ❌ PROHIBITED - Inline Data + ```python + # DO NOT do this + data = [10, 20, 30, 40, 50] + labels = ['A', 'B', 'C', 'D', 'E'] + ``` + + ### ✅ REQUIRED - External Data Files + ```python + # Always load data from external files + import pandas as pd + + # Load data from CSV + data = pd.read_csv('/tmp/gh-aw/python/data/data.csv') + + # Or from JSON + data = pd.read_json('/tmp/gh-aw/python/data/data.json') + ``` + + ## Chart Generation Best Practices + + ### High-Quality Chart Settings + + ```python + import matplotlib.pyplot as plt + import seaborn as sns + + # Set style for better aesthetics + sns.set_style("whitegrid") + sns.set_palette("husl") + + # Create figure with high DPI + fig, ax = plt.subplots(figsize=(10, 6), dpi=300) + + # Your plotting code here + # ... + + # Save with high quality + plt.savefig('/tmp/gh-aw/python/charts/chart.png', + dpi=300, + bbox_inches='tight', + facecolor='white', + edgecolor='none') + ``` + + ### Chart Quality Guidelines + + - **DPI**: Use 300 or higher for publication quality + - **Figure Size**: Standard is 10x6 inches (adjustable based on needs) + - **Labels**: Always include clear axis labels and titles + - **Legend**: Add legends when plotting multiple series + - **Grid**: Enable grid lines for easier reading + - **Colors**: Use colorblind-friendly palettes (seaborn defaults are good) + + ## Including Images in Reports + + When creating reports (issues, discussions, etc.), use the `upload asset` tool to make images URL-addressable and include them in markdown: + + ### Step 1: Generate and Upload Chart + ```python + # Generate your chart + plt.savefig('/tmp/gh-aw/python/charts/my_chart.png', dpi=300, bbox_inches='tight') + ``` + + ### Step 2: Upload as Asset + Use the `upload asset` tool to upload the chart file. The tool will return a GitHub raw content URL. + + ### Step 3: Include in Markdown Report + When creating your discussion or issue, include the image using markdown: + + ```markdown + ## Visualization Results - Goal: prove we can **update a Project item** that points to a real GitHub Issue. + ![Chart Description](https://raw.githubusercontent.com/owner/repo/assets/workflow-name/my_chart.png) - Project board: + The chart above shows... + ``` - Task: Update all issue items that are currently on the project board with Status "In Progress". + **Important**: Assets are published to an orphaned git branch and become URL-addressable after workflow completion. + + ## Cache Memory Integration + + The cache memory at `/tmp/gh-aw/cache-memory/` is available for storing reusable code: + + **Helper Functions to Cache:** + - Data loading utilities: `data_loader.py` + - Chart styling functions: `chart_utils.py` + - Common data transformations: `transforms.py` + + **Check Cache Before Creating:** + ```bash + # Check if helper exists in cache + if [ -f /tmp/gh-aw/cache-memory/data_loader.py ]; then + cp /tmp/gh-aw/cache-memory/data_loader.py /tmp/gh-aw/python/ + echo "Using cached data_loader.py" + fi + ``` + + **Save to Cache for Future Runs:** + ```bash + # Save useful helpers to cache + cp /tmp/gh-aw/python/data_loader.py /tmp/gh-aw/cache-memory/ + echo "Saved data_loader.py to cache for future runs" + ``` + + ## Complete Example Workflow + + ```python + #!/usr/bin/env python3 + """ + Example data visualization script + Generates a bar chart from external data + """ + import pandas as pd + import matplotlib.pyplot as plt + import seaborn as sns + + # Set style + sns.set_style("whitegrid") + sns.set_palette("husl") + + # Load data from external file (NEVER inline) + data = pd.read_csv('/tmp/gh-aw/python/data/data.csv') + + # Process data + summary = data.groupby('category')['value'].sum() + + # Create chart + fig, ax = plt.subplots(figsize=(10, 6), dpi=300) + summary.plot(kind='bar', ax=ax) + + # Customize + ax.set_title('Data Summary by Category', fontsize=16, fontweight='bold') + ax.set_xlabel('Category', fontsize=12) + ax.set_ylabel('Value', fontsize=12) + ax.grid(True, alpha=0.3) + + # Save chart + plt.savefig('/tmp/gh-aw/python/charts/chart.png', + dpi=300, + bbox_inches='tight', + facecolor='white') + + print("Chart saved to /tmp/gh-aw/python/charts/chart.png") + ``` + + ## Error Handling + + **Check File Existence:** + ```python + import os + + data_file = '/tmp/gh-aw/python/data/data.csv' + if not os.path.exists(data_file): + raise FileNotFoundError(f"Data file not found: {data_file}") + ``` + + **Validate Data:** + ```python + # Check for required columns + required_cols = ['category', 'value'] + missing = set(required_cols) - set(data.columns) + if missing: + raise ValueError(f"Missing columns: {missing}") + ``` + + ## Artifact Upload + + Charts and source files are automatically uploaded as artifacts: + + **Charts Artifact:** + - Name: `data-charts` + - Contents: PNG files from `/tmp/gh-aw/python/charts/` + - Retention: 30 days + + **Source and Data Artifact:** + - Name: `python-source-and-data` + - Contents: Python scripts and data files + - Retention: 30 days + + Both artifacts are uploaded with `if: always()` condition, ensuring they're available even if the workflow fails. + + ## Tips for Success + + 1. **Always Separate Data**: Store data in files, never inline in code + 2. **Use Cache Memory**: Store reusable helpers for faster execution + 3. **High Quality Charts**: Use DPI 300+ and proper sizing + 4. **Clear Documentation**: Add docstrings and comments + 5. **Error Handling**: Validate data and check file existence + 6. **Type Hints**: Use type annotations for better code quality + 7. **Seaborn Defaults**: Leverage seaborn for better aesthetics + 8. **Reproducibility**: Set random seeds when needed + + ## Common Data Sources + + Based on common use cases: + + **Repository Statistics:** + ```python + # Collect via GitHub API, save to data.csv + # Then load and visualize + data = pd.read_csv('/tmp/gh-aw/python/data/repo_stats.csv') + ``` + + **Workflow Metrics:** + ```python + # Collect via GitHub Actions API, save to data.json + data = pd.read_json('/tmp/gh-aw/python/data/workflow_metrics.json') + ``` + + **Sample Data Generation:** + ```python + # Generate with NumPy, save to file first + import numpy as np + data = np.random.randn(100, 2) + df = pd.DataFrame(data, columns=['x', 'y']) + df.to_csv('/tmp/gh-aw/python/data/sample_data.csv', index=False) + + # Then load it back (demonstrating the pattern) + data = pd.read_csv('/tmp/gh-aw/python/data/sample_data.csv') + ``` + + # Test Upload Asset with Python Graph Generation + + Create a dummy graph using Python and matplotlib, then upload it as an asset. + + **Requirements:** + 1. Use Python to create a simple graph (e.g., a sine wave or bar chart) using matplotlib + 2. Save the graph as a PNG file to /tmp/graph.png + 3. Use the `upload_asset` tool to upload the graph + 4. The tool should return a URL where the graph can be accessed + 5. Create an issue that includes the graph using markdown image syntax + 6. Verify that: + - The graph file was created successfully + - The asset was uploaded and a URL was returned + - The issue was created with the embedded graph image PROMPT_EOF - name: Append XPIA security instructions to prompt @@ -2014,6 +3830,31 @@ jobs: When you need to create temporary files or directories during your work, always use the /tmp/gh-aw/agent/ directory that has been pre-created for you. Do NOT use the root /tmp/ directory directly. + PROMPT_EOF + - name: Append cache memory instructions to prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: | + cat << 'PROMPT_EOF' >> "$GH_AW_PROMPT" + + --- + + ## Cache Folder Available + + You have access to a persistent cache folder at `/tmp/gh-aw/cache-memory/` where you can read and write files to create memories and store information. + + - **Read/Write Access**: You can freely read from and write to any files in this folder + - **Persistence**: Files in this folder persist across workflow runs via GitHub Actions cache + - **Last Write Wins**: If multiple processes write to the same file, the last write will be preserved + - **File Share**: Use this as a simple file share - organize files as you see fit + + Examples of what you can store: + - `/tmp/gh-aw/cache-memory/notes.txt` - general notes and observations + - `/tmp/gh-aw/cache-memory/preferences.json` - user preferences and settings + - `/tmp/gh-aw/cache-memory/history.log` - activity history and logs + - `/tmp/gh-aw/cache-memory/state/` - organized state files in subdirectories + + Feel free to create, read, update, and organize files in this folder as needed for your tasks. PROMPT_EOF - name: Append safe outputs instructions to prompt env: @@ -2028,7 +3869,7 @@ jobs: To create or modify GitHub resources (issues, discussions, pull requests, etc.), you MUST call the appropriate safe output tool. Simply writing content will NOT work - the workflow requires actual tool calls. - **Available tools**: missing_tool, noop, update_project + **Available tools**: create_issue, missing_tool, noop, upload_asset **Critical**: Tool calls write structured data that downstream jobs process. Without tool calls, follow-up actions will be skipped. @@ -2314,23 +4155,26 @@ jobs: - name: Execute GitHub Copilot CLI id: agentic_execution # Copilot CLI tool arguments (sorted): - # --allow-tool github - # --allow-tool safeoutputs - timeout-minutes: 20 + timeout-minutes: 5 run: | set -o pipefail - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.7.0 \ - -- /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool github --allow-tool safeoutputs --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"} \ + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --mount /tmp:/tmp:rw --mount "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw" --mount /usr/bin/date:/usr/bin/date:ro --mount /usr/bin/gh:/usr/bin/gh:ro --mount /usr/bin/yq:/usr/bin/yq:ro --mount /usr/local/bin/copilot:/usr/local/bin/copilot:ro --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --image-tag 0.7.0 \ + -- /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"${GH_AW_MODEL_AGENT_COPILOT:+ --model "$GH_AW_MODEL_AGENT_COPILOT"} \ 2>&1 | tee /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" + GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" + GH_AW_ASSETS_MAX_SIZE_KB: 10240 + GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json GH_AW_MODEL_AGENT_COPILOT: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_DEBUG: 1 GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_WORKSPACE: ${{ github.workspace }} @@ -2446,12 +4290,11 @@ jobs: } await main(); env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,TEST_ORG_PROJECT_WRITE' + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SECRET_TEST_ORG_PROJECT_WRITE: ${{ secrets.TEST_ORG_PROJECT_WRITE }} - name: Upload Safe Outputs if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 @@ -2464,7 +4307,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org" + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} with: @@ -3714,6 +5557,13 @@ jobs: name: mcp-logs path: /tmp/gh-aw/mcp-logs/ if-no-files-found: ignore + - name: Upload SafeInputs logs + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: safeinputs + path: /tmp/gh-aw/safe-inputs/logs/ + if-no-files-found: ignore - name: Parse agent logs for step summary if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -5357,6 +7207,19 @@ jobs: name: agent-stdio.log path: /tmp/gh-aw/agent-stdio.log if-no-files-found: warn + - name: Upload cache-memory data as artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: always() + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory + - name: Upload safe outputs assets + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: safe-outputs-assets + path: /tmp/gh-aw/safeoutputs/assets/ + if-no-files-found: ignore - name: Validate agent logs for errors if: always() uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 @@ -5600,6 +7463,8 @@ jobs: - agent - detection - safe_outputs + - update_cache_memory + - upload_assets if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -6137,7 +8002,7 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: WORKFLOW_NAME: "Dev" - WORKFLOW_DESCRIPTION: "Update issues on an org-owned Project Board" + WORKFLOW_DESCRIPTION: "Test upload-asset with Python graph generation" with: script: | const fs = require('fs'); @@ -6366,11 +8231,16 @@ jobs: runs-on: ubuntu-slim permissions: contents: read + issues: write timeout-minutes: 15 env: GH_AW_ENGINE_ID: "copilot" GH_AW_WORKFLOW_ID: "dev" GH_AW_WORKFLOW_NAME: "Dev" + outputs: + create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} + create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} + create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6388,6 +8258,156 @@ jobs: shell: bash run: | mkdir -p /tmp/gh-aw/scripts + cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' + // @ts-check + /// + + /** + * Add expiration XML comment to body lines if expires is set + * @param {string[]} bodyLines - Array of body lines to append to + * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") + * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") + * @returns {void} + */ + function addExpirationComment(bodyLines, envVarName, entityType) { + const expiresEnv = process.env[envVarName]; + if (expiresEnv) { + const expiresDays = parseInt(expiresEnv, 10); + if (!isNaN(expiresDays) && expiresDays > 0) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + expiresDays); + const expirationISO = expirationDate.toISOString(); + bodyLines.push(``); + core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); + } + } + } + + module.exports = { + addExpirationComment, + }; + + EOF_33eff070 + cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' + // @ts-check + /// + + /** + * Generates an XML comment marker with agentic workflow metadata for traceability. + * This marker enables searching and tracing back items generated by an agentic workflow. + * + * Note: This function is duplicated in messages_footer.cjs. While normally we would + * consolidate to a shared module, importing messages_footer.cjs here would cause the + * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in + * a warning message, breaking tests that check for env var declarations. + * + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @returns {string} XML comment marker with workflow metadata + */ + function generateXMLMarker(workflowName, runUrl) { + // Read engine metadata from environment variables + const engineId = process.env.GH_AW_ENGINE_ID || ""; + const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; + const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; + const trackerId = process.env.GH_AW_TRACKER_ID || ""; + + // Build the key-value pairs for the marker + const parts = []; + + // Always include agentic-workflow name + parts.push(`agentic-workflow: ${workflowName}`); + + // Add tracker-id if available (for searchability and tracing) + if (trackerId) { + parts.push(`tracker-id: ${trackerId}`); + } + + // Add engine ID if available + if (engineId) { + parts.push(`engine: ${engineId}`); + } + + // Add version if available + if (engineVersion) { + parts.push(`version: ${engineVersion}`); + } + + // Add model if available + if (engineModel) { + parts.push(`model: ${engineModel}`); + } + + // Always include run URL + parts.push(`run: ${runUrl}`); + + // Return the XML comment marker + return ``; + } + + /** + * Generate footer with AI attribution and workflow installation instructions + * @param {string} workflowName - Name of the workflow + * @param {string} runUrl - URL of the workflow run + * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) + * @param {string} workflowSourceURL - GitHub URL for the workflow source + * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow + * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow + * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow + * @returns {string} Footer text + */ + function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { + let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; + + // Add reference to triggering issue/PR/discussion if available + if (triggeringIssueNumber) { + footer += ` for #${triggeringIssueNumber}`; + } else if (triggeringPRNumber) { + footer += ` for #${triggeringPRNumber}`; + } else if (triggeringDiscussionNumber) { + footer += ` for discussion #${triggeringDiscussionNumber}`; + } + + if (workflowSource && workflowSourceURL) { + footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; + } + + // Add XML comment marker for traceability + footer += "\n\n" + generateXMLMarker(workflowName, runUrl); + + footer += "\n"; + return footer; + } + + module.exports = { + generateFooter, + generateXMLMarker, + }; + + EOF_88f9d2d4 + cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' + // @ts-check + /// + + /** + * Get tracker-id from environment variable, log it, and optionally format it + * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value + * @returns {string} Tracker ID in requested format or empty string + */ + function getTrackerID(format) { + const trackerID = process.env.GH_AW_TRACKER_ID || ""; + if (trackerID) { + core.info(`Tracker ID: ${trackerID}`); + return format === "markdown" ? `\n\n` : trackerID; + } + return ""; + } + + module.exports = { + getTrackerID, + }; + + EOF_bfad4250 cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' // @ts-check /// @@ -6457,459 +8477,963 @@ jobs: core.info(`Agent output content length: ${outputContent.length}`); - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; + // Parse the validated output JSON + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + + // Validate items array exists + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + + return { success: true, items: validatedOutput.items }; + } + + module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; + + EOF_b93f537f + cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' + // @ts-check + /** + * Remove duplicate title from description + * @module remove_duplicate_title + */ + + /** + * Removes duplicate title from the beginning of description content. + * If the description starts with a header (# or ## or ### etc.) that matches + * the title, it will be removed along with any trailing newlines. + * + * @param {string} title - The title text to match and remove + * @param {string} description - The description content that may contain duplicate title + * @returns {string} The description with duplicate title removed + */ + function removeDuplicateTitleFromDescription(title, description) { + // Handle null/undefined/empty inputs + if (!title || typeof title !== "string") { + return description || ""; + } + if (!description || typeof description !== "string") { + return ""; + } + + const trimmedTitle = title.trim(); + const trimmedDescription = description.trim(); + + if (!trimmedTitle || !trimmedDescription) { + return trimmedDescription; + } + + // Match any header level (# to ######) followed by the title at the start + // This regex matches: + // - Start of string + // - One or more # characters + // - One or more spaces + // - The exact title (escaped for regex special chars) + // - Optional trailing spaces + // - Optional newlines after the header + const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); + + if (headerRegex.test(trimmedDescription)) { + return trimmedDescription.replace(headerRegex, "").trim(); + } + + return trimmedDescription; + } + + module.exports = { removeDuplicateTitleFromDescription }; + + EOF_bb4a8126 + cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' + // @ts-check + /// + + /** + * Repository-related helper functions for safe-output scripts + * Provides common repository parsing, validation, and resolution logic + */ + + /** + * Parse the allowed repos from environment variable + * @returns {Set} Set of allowed repository slugs + */ + function parseAllowedRepos() { + const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; + const set = new Set(); + if (allowedReposEnv) { + allowedReposEnv + .split(",") + .map(repo => repo.trim()) + .filter(repo => repo) + .forEach(repo => set.add(repo)); + } + return set; + } + + /** + * Get the default target repository + * @returns {string} Repository slug in "owner/repo" format + */ + function getDefaultTargetRepo() { + // First check if there's a target-repo override + const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; + if (targetRepoSlug) { + return targetRepoSlug; + } + // Fall back to context repo + return `${context.repo.owner}/${context.repo.repo}`; + } + + /** + * Validate that a repo is allowed for operations + * @param {string} repo - Repository slug to validate + * @param {string} defaultRepo - Default target repository + * @param {Set} allowedRepos - Set of explicitly allowed repos + * @returns {{valid: boolean, error: string|null}} + */ + function validateRepo(repo, defaultRepo, allowedRepos) { + // Default repo is always allowed + if (repo === defaultRepo) { + return { valid: true, error: null }; + } + // Check if it's in the allowed repos list + if (allowedRepos.has(repo)) { + return { valid: true, error: null }; + } + return { + valid: false, + error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, + }; + } + + /** + * Parse owner and repo from a repository slug + * @param {string} repoSlug - Repository slug in "owner/repo" format + * @returns {{owner: string, repo: string}|null} + */ + function parseRepoSlug(repoSlug) { + const parts = repoSlug.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return null; + } + return { owner: parts[0], repo: parts[1] }; + } + + module.exports = { + parseAllowedRepos, + getDefaultTargetRepo, + validateRepo, + parseRepoSlug, + }; + + EOF_0e3d051f + cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' + // @ts-check + /** + * Sanitize label content for GitHub API + * Removes control characters, ANSI codes, and neutralizes @mentions + * @module sanitize_label_content + */ + + /** + * Sanitizes label content by removing control characters, ANSI escape codes, + * and neutralizing @mentions to prevent unintended notifications. + * + * @param {string} content - The label content to sanitize + * @returns {string} The sanitized label content + */ + function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + // Remove ANSI escape sequences FIRST (before removing control chars) + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + // Then remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); + } + + module.exports = { sanitizeLabelContent }; + + EOF_4b431e5e + cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' + // @ts-check + /// + + /** + * Generate a staged mode preview summary and write it to the step summary. + * + * @param {Object} options - Configuration options for the preview + * @param {string} options.title - The main title for the preview (e.g., "Create Issues") + * @param {string} options.description - Description of what would happen if staged mode was disabled + * @param {Array} options.items - Array of items to preview + * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown + * @returns {Promise} + */ + async function generateStagedPreview(options) { + const { title, description, items, renderItem } = options; + + let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; + summaryContent += `${description}\n\n`; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += renderItem(item, i); + summaryContent += "---\n\n"; + } + + try { + await core.summary.addRaw(summaryContent).write(); + core.info(summaryContent); + core.info(`📝 ${title} preview written to step summary`); + } catch (error) { + core.setFailed(error instanceof Error ? error : String(error)); + } + } + + module.exports = { generateStagedPreview }; + + EOF_8386ee20 + cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' + // @ts-check + /// + + const crypto = require("crypto"); + + /** + * Regex pattern for matching temporary ID references in text + * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) + */ + const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; + + /** + * @typedef {Object} RepoIssuePair + * @property {string} repo - Repository slug in "owner/repo" format + * @property {number} number - Issue or discussion number + */ + + /** + * Generate a temporary ID with aw_ prefix for temporary issue IDs + * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) + */ + function generateTemporaryId() { + return "aw_" + crypto.randomBytes(6).toString("hex"); + } + + /** + * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) + * @param {any} value - The value to check + * @returns {boolean} True if the value is a valid temporary ID + */ + function isTemporaryId(value) { + if (typeof value === "string") { + return /^aw_[0-9a-f]{12}$/i.test(value); + } + return false; + } + + /** + * Normalize a temporary ID to lowercase for consistent map lookups + * @param {string} tempId - The temporary ID to normalize + * @returns {string} Lowercase temporary ID + */ + function normalizeTemporaryId(tempId) { + return String(tempId).toLowerCase(); + } + + /** + * Replace temporary ID references in text with actual issue numbers + * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) + * @param {string} text - The text to process + * @param {Map} tempIdMap - Map of temporary_id to {repo, number} + * @param {string} [currentRepo] - Current repository slug for same-repo references + * @returns {string} Text with temporary IDs replaced with issue numbers + */ + function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); + if (resolved !== undefined) { + // If we have a currentRepo and the issue is in the same repo, use short format + if (currentRepo && resolved.repo === currentRepo) { + return `#${resolved.number}`; + } + // Otherwise use full repo#number format for cross-repo references + return `${resolved.repo}#${resolved.number}`; + } + // Return original if not found (it may be created later) + return match; + }); + } + + /** + * Replace temporary ID references in text with actual issue numbers (legacy format) + * This is a compatibility function that works with Map + * Format: #aw_XXXXXXXXXXXX -> #123 + * @param {string} text - The text to process + * @param {Map} tempIdMap - Map of temporary_id to issue number + * @returns {string} Text with temporary IDs replaced with issue numbers + */ + function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { + return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { + const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); + if (issueNumber !== undefined) { + return `#${issueNumber}`; + } + // Return original if not found (it may be created later) + return match; + }); + } + + /** + * Load the temporary ID map from environment variable + * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) + * @returns {Map} Map of temporary_id to {repo, number} + */ + function loadTemporaryIdMap() { + const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; + if (!mapJson || mapJson === "{}") { + return new Map(); + } + try { + const mapObject = JSON.parse(mapJson); + /** @type {Map} */ + const result = new Map(); + + for (const [key, value] of Object.entries(mapObject)) { + const normalizedKey = normalizeTemporaryId(key); + if (typeof value === "number") { + // Legacy format: number only, use context repo + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + result.set(normalizedKey, { repo: contextRepo, number: value }); + } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { + // New format: {repo, number} + result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); + } + } + return result; + } catch (error) { + if (typeof core !== "undefined") { + core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); + } + return new Map(); + } + } + + /** + * Resolve an issue number that may be a temporary ID or an actual issue number + * Returns structured result with the resolved number, repo, and metadata + * @param {any} value - The value to resolve (can be temporary ID, number, or string) + * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} + * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} + */ + function resolveIssueNumber(value, temporaryIdMap) { + if (value === undefined || value === null) { + return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; + } + + // Check if it's a temporary ID + const valueStr = String(value); + if (isTemporaryId(valueStr)) { + const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); + if (resolvedPair !== undefined) { + return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; + } + return { + resolved: null, + wasTemporaryId: true, + errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, + }; } - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; + // It's a real issue number - use context repo as default + const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); + if (isNaN(issueNumber) || issueNumber <= 0) { + return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; } - return { success: true, items: validatedOutput.items }; + const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; + return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; } - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; + /** + * Serialize the temporary ID map to JSON for output + * @param {Map} tempIdMap - Map of temporary_id to {repo, number} + * @returns {string} JSON string of the map + */ + function serializeTemporaryIdMap(tempIdMap) { + const obj = Object.fromEntries(tempIdMap); + return JSON.stringify(obj); + } - EOF_b93f537f - - name: Update Project - id: update_project - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project')) + module.exports = { + TEMPORARY_ID_PATTERN, + generateTemporaryId, + isTemporaryId, + normalizeTemporaryId, + replaceTemporaryIdReferences, + replaceTemporaryIdReferencesLegacy, + loadTemporaryIdMap, + resolveIssueNumber, + serializeTemporaryIdMap, + }; + + EOF_795429aa + - name: Create Issue + id: create_issue + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ISSUE_TITLE_PREFIX: "[Dev Test] " with: - github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | globalThis.github = github; globalThis.context = context; globalThis.core = core; globalThis.exec = exec; globalThis.io = io; + const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function logGraphQLError(error, operation) { - (core.info(`GraphQL Error during: ${operation}`), core.info(`Message: ${error.message}`)); - const errorList = Array.isArray(error.errors) ? error.errors : [], - hasInsufficientScopes = errorList.some(e => e && "INSUFFICIENT_SCOPES" === e.type), - hasNotFound = errorList.some(e => e && "NOT_FOUND" === e.type); - (hasInsufficientScopes - ? core.info( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project." - ) - : hasNotFound && - /projectV2\b/.test(error.message) && - core.info( - "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." - ), - error.errors && - (core.info(`Errors array (${error.errors.length} error(s)):`), - error.errors.forEach((err, idx) => { - (core.info(` [${idx + 1}] ${err.message}`), - err.type && core.info(` Type: ${err.type}`), - err.path && core.info(` Path: ${JSON.stringify(err.path)}`), - err.locations && core.info(` Locations: ${JSON.stringify(err.locations)}`)); - })), - error.request && core.info(`Request: ${JSON.stringify(error.request, null, 2)}`), - error.data && core.info(`Response data: ${JSON.stringify(error.data, null, 2)}`)); - } - function parseProjectInput(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); - if (!urlMatch) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return urlMatch[1]; - } - function parseProjectUrl(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); - if (!match) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] }; - } - async function listAccessibleProjectsV2(projectInfo) { - const baseQuery = - "projectsV2(first: 100) {\n totalCount\n nodes {\n id\n number\n title\n closed\n url\n }\n edges {\n node {\n id\n number\n title\n closed\n url\n }\n }\n }"; - if ("orgs" === projectInfo.scope) { - const result = await github.graphql(`query($login: String!) {\n organization(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.organization && result.organization.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; + const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); + const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); + const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); + const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); + const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); + const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); + const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("issues_to_assign_copilot", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + return; } - const result = await github.graphql(`query($login: String!) {\n user(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.user && result.user.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; - } - function summarizeProjectsV2(projects, limit = 20) { - if (!Array.isArray(projects) || 0 === projects.length) return "(none)"; - const normalized = projects - .filter(p => p && "number" == typeof p.number && "string" == typeof p.title) - .slice(0, limit) - .map(p => `#${p.number} ${p.closed ? "(closed) " : ""}${p.title}`); - return normalized.length > 0 ? normalized.join("; ") : "(none)"; - } - function summarizeEmptyProjectsV2List(list) { - const total = "number" == typeof list.totalCount ? list.totalCount : void 0, - d = list && list.diagnostics, - diag = d ? ` nodes=${d.rawNodesCount} (null=${d.nullNodesCount}), edges=${d.rawEdgesCount} (nullNode=${d.nullEdgeNodesCount})` : ""; - return "number" == typeof total && total > 0 - ? `(none; totalCount=${total} but returned 0 readable project nodes${diag}. This often indicates the token can see the org/user but lacks Projects v2 access, or the org enforces SSO and the token is not authorized.)` - : `(none${diag})`; - } - async function resolveProjectV2(projectInfo, projectNumberInt) { - try { - if ("orgs" === projectInfo.scope) { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n organization(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.organization && direct.organization.projectV2; - if (project) return project; - } else { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n user(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.user && direct.user.projectV2; - if (project) return project; + const createIssueItems = result.items.filter(item => item.type === "create_issue"); + if (createIssueItems.length === 0) { + core.info("No create-issue items found in agent output"); + return; + } + core.info(`Found ${createIssueItems.length} create-issue item(s)`); + const allowedRepos = parseAllowedRepos(); + const defaultTargetRepo = getDefaultTargetRepo(); + core.info(`Default target repo: ${defaultTargetRepo}`); + if (allowedRepos.size > 0) { + core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); + } + if (isStaged) { + await generateStagedPreview({ + title: "Create Issues", + description: "The following issues would be created if staged mode was disabled:", + items: createIssueItems, + renderItem: (item, index) => { + let content = `#### Issue ${index + 1}\n`; + content += `**Title:** ${item.title || "No title provided"}\n\n`; + if (item.temporary_id) { + content += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + content += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + content += `**Body:**\n${item.body}\n\n`; + } + if (item.labels && item.labels.length > 0) { + content += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + if (item.parent) { + content += `**Parent:** ${item.parent}\n\n`; + } + return content; + }, + }); + return; + } + const parentIssueNumber = context.payload?.issue?.number; + const temporaryIdMap = new Map(); + const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; + const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); + const triggeringDiscussionNumber = context.payload?.discussion?.number; + const labelsEnv = process.env.GH_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + const createdIssues = []; + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; + const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); + if (!repoValidation.valid) { + core.warning(`Skipping issue: ${repoValidation.error}`); + continue; } - } catch (error) { - core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${error.message}`); - } - const list = await listAccessibleProjectsV2(projectInfo), - nodes = Array.isArray(list.nodes) ? list.nodes : [], - found = nodes.find(p => p && "number" == typeof p.number && p.number === projectNumberInt); - if (found) return found; - const summary = nodes.length > 0 ? summarizeProjectsV2(nodes) : summarizeEmptyProjectsV2List(list), - total = "number" == typeof list.totalCount ? ` (totalCount=${list.totalCount})` : "", - who = "orgs" === projectInfo.scope ? `org ${projectInfo.ownerLogin}` : `user ${projectInfo.ownerLogin}`; - throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`); - } - function generateCampaignId(projectUrl, projectNumber) { - const urlMatch = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects/); - return `${`${urlMatch ? urlMatch[2] : "project"}-project-${projectNumber}` - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 30)}-${Date.now().toString(36).substring(0, 8)}`; - } - async function updateProject(output) { - const { owner, repo } = context.repo, - projectInfo = parseProjectUrl(output.project), - projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id; - try { - let repoResult; - (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); - try { - repoResult = await github.graphql( - "query($owner: String!, $repo: String!) {\n repository(owner: $owner, name: $repo) {\n id\n owner {\n id\n __typename\n }\n }\n }", - { owner, repo } - ); - } catch (error) { - throw (logGraphQLError(error, "Fetching repository information"), error); + const repoParts = parseRepoSlug(itemRepo); + if (!repoParts) { + core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); + continue; } - const repositoryId = repoResult.repository.id, - ownerType = repoResult.repository.owner.__typename; - core.info(`✓ Repository: ${owner}/${repo} (${ownerType})`); - try { - const viewerResult = await github.graphql("query {\n viewer {\n login\n }\n }"); - viewerResult && viewerResult.viewer && viewerResult.viewer.login && core.info(`✓ Authenticated as: ${viewerResult.viewer.login}`); - } catch (viewerError) { - core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`); - } - let projectId; - core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); - let resolvedProjectNumber = projectNumberFromUrl; - try { - const projectNumberInt = parseInt(projectNumberFromUrl, 10); - if (!Number.isFinite(projectNumberInt)) throw new Error(`Invalid project number parsed from URL: ${projectNumberFromUrl}`); - const project = await resolveProjectV2(projectInfo, projectNumberInt); - ((projectId = project.id), (resolvedProjectNumber = String(project.number)), core.info(`✓ Resolved project #${resolvedProjectNumber} (${projectInfo.ownerLogin}) (ID: ${projectId})`)); - } catch (error) { - throw (logGraphQLError(error, "Resolving project from URL"), error); + const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); + core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); + core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); + core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); + let effectiveParentIssueNumber; + let effectiveParentRepo = itemRepo; + if (createIssueItem.parent !== undefined) { + if (isTemporaryId(createIssueItem.parent)) { + const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); + if (resolvedParent !== undefined) { + effectiveParentIssueNumber = resolvedParent.number; + effectiveParentRepo = resolvedParent.repo; + core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } else { + core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); + effectiveParentIssueNumber = undefined; + } + } else { + effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); + if (isNaN(effectiveParentIssueNumber)) { + core.warning(`Invalid parent value: ${createIssueItem.parent}`); + effectiveParentIssueNumber = undefined; + } + } + } else { + const contextRepo = `${context.repo.owner}/${context.repo.repo}`; + if (itemRepo === contextRepo) { + effectiveParentIssueNumber = parentIssueNumber; + } + } + core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); + if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { + core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels]; + } + labels = labels + .filter(label => !!label) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + let title = createIssueItem.title ? createIssueItem.title.trim() : ""; + let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + let bodyLines = processedBody.split("\n"); + if (!title) { + title = createIssueItem.body || "Agent Output"; + } + const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (effectiveParentIssueNumber) { + core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); + if (effectiveParentRepo === itemRepo) { + bodyLines.push(`Related to #${effectiveParentIssueNumber}`); + } else { + bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } } - core.info("[3/5] Linking project to repository..."); + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; + const runId = context.runId; + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + const trackerIDComment = getTrackerID("markdown"); + if (trackerIDComment) { + bodyLines.push(trackerIDComment); + } + addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); + bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); + const body = bodyLines.join("\n").trim(); + core.info(`Creating issue in ${itemRepo} with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); try { - await github.graphql( - "mutation($projectId: ID!, $repositoryId: ID!) {\n linkProjectV2ToRepository(input: {\n projectId: $projectId,\n repositoryId: $repositoryId\n }) {\n repository {\n id\n }\n }\n }", - { projectId, repositoryId } - ); - } catch (linkError) { - (linkError.message && linkError.message.includes("already linked")) || (logGraphQLError(linkError, "Linking project to repository"), core.warning(`Could not link project: ${linkError.message}`)); - } - (core.info("✓ Project linked to repository"), core.info("[4/5] Processing content (issue/PR/draft) if specified...")); - const hasContentNumber = void 0 !== output.content_number && null !== output.content_number, - hasIssue = void 0 !== output.issue && null !== output.issue, - hasPullRequest = void 0 !== output.pull_request && null !== output.pull_request, - values = []; - if ( - (hasContentNumber && values.push({ key: "content_number", value: output.content_number }), - hasIssue && values.push({ key: "issue", value: output.issue }), - hasPullRequest && values.push({ key: "pull_request", value: output.pull_request }), - values.length > 1) - ) { - const uniqueValues = [...new Set(values.map(v => String(v.value)))], - list = values.map(v => `${v.key}=${v.value}`).join(", "), - descriptor = uniqueValues.length > 1 ? "different values" : `same value "${uniqueValues[0]}"`; - core.warning(`Multiple content number fields (${descriptor}): ${list}. Using priority content_number > issue > pull_request.`); - } - (hasIssue && core.warning('Field "issue" deprecated; use "content_number" instead.'), hasPullRequest && core.warning('Field "pull_request" deprecated; use "content_number" instead.')); - if ("draft_issue" === output.content_type) { - values.length > 0 && core.warning('content_number/issue/pull_request is ignored when content_type is "draft_issue".'); - const draftTitle = "string" == typeof output.draft_title ? output.draft_title.trim() : ""; - if (!draftTitle) throw new Error('Invalid draft_title. When content_type is "draft_issue", draft_title is required and must be a non-empty string.'); - const draftBody = "string" == typeof output.draft_body ? output.draft_body : void 0; - const itemId = ( - await github.graphql( - "mutation($projectId: ID!, $title: String!, $body: String) {\n addProjectV2DraftIssue(input: {\n projectId: $projectId,\n title: $title,\n body: $body\n }) {\n projectItem {\n id\n }\n }\n }", - { projectId, title: draftTitle, body: draftBody } - ) - ).addProjectV2DraftIssue.projectItem.id; - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = ( - await github.graphql( - "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", - { projectId } - ) - ).node.fields.nodes; - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - if (!field) - if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; + const { data: issue } = await github.rest.issues.create({ + owner: repoParts.owner, + repo: repoParts.repo, + title: title, + body: body, + labels: labels, + }); + core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); + createdIssues.push({ ...issue, _repo: itemRepo }); + temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); + core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); + core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); + if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { + core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); + try { + core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); + const getIssueNodeIdQuery = ` + query($owner: String!, $repo: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + id + } } - if (field.dataType === "DATE") valueToSet = { date: String(fieldValue) }; - else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) - try { - const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], - updatedField = ( - await github.graphql( - "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", - { fieldId: field.id, fieldName: field.name, options: allOptions } - ) - ).updateProjectV2Field.projectV2Field; - ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); - } catch (createError) { - core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); - continue; + } + `; + const parentResult = await github.graphql(getIssueNodeIdQuery, { + owner: repoParts.owner, + repo: repoParts.repo, + issueNumber: effectiveParentIssueNumber, + }); + const parentNodeId = parentResult.repository.issue.id; + core.info(`Parent issue node ID: ${parentNodeId}`); + core.info(`Fetching node ID for child issue #${issue.number}...`); + const childResult = await github.graphql(getIssueNodeIdQuery, { + owner: repoParts.owner, + repo: repoParts.repo, + issueNumber: issue.number, + }); + const childNodeId = childResult.repository.issue.id; + core.info(`Child issue node ID: ${childNodeId}`); + core.info(`Executing addSubIssue mutation...`); + const addSubIssueMutation = ` + mutation($issueId: ID!, $subIssueId: ID!) { + addSubIssue(input: { + issueId: $issueId, + subIssueId: $subIssueId + }) { + subIssue { + id + number + } } - if (!option) { - core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); - continue; } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } - } - core.setOutput("item-id", itemId); - return; - } - let contentNumber = null; - if (hasContentNumber || hasIssue || hasPullRequest) { - const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request, - sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); - if (sanitizedContentNumber) { - if (!/^\d+$/.test(sanitizedContentNumber)) throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`); - contentNumber = Number.parseInt(sanitizedContentNumber, 10); - } else core.warning("Content number field provided but empty; skipping project item update."); - } - if (null !== contentNumber) { - const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", - contentQuery = - "Issue" === contentType - ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }" - : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n createdAt\n closedAt\n }\n }\n }", - contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }), - contentData = "Issue" === contentType ? contentResult.repository.issue : contentResult.repository.pullRequest, - contentId = contentData.id, - createdAt = contentData.createdAt, - closedAt = contentData.closedAt, - existingItem = await (async function (projectId, contentId) { - let hasNextPage = !0, - endCursor = null; - for (; hasNextPage; ) { - const result = await github.graphql( - "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", - { projectId, after: endCursor } - ), - found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); - if (found) return found; - ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); - } - return null; - })(projectId, contentId); - let itemId; - if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); - else { - itemId = ( - await github.graphql( - "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", - { projectId, contentId } - ) - ).addProjectV2ItemById.item.id; - if (campaignId) { + `; + await github.graphql(addSubIssueMutation, { + issueId: parentNodeId, + subIssueId: childNodeId, + }); + core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); + } catch (error) { + core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); + core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); + core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); + await github.rest.issues.createComment({ + owner: repoParts.owner, + repo: repoParts.repo, + issue_number: effectiveParentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); + } catch (commentError) { + core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); } } + } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { + core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); + } else { + core.info(`Debug: No parent issue number set, skipping sub-issue linking`); } - const fieldsToUpdate = output.fields ? { ...output.fields } : {}; - if (Object.keys(fieldsToUpdate).length > 0) { - const projectFields = ( - await github.graphql( - "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n dataType\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n dataType\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", - { projectId } - ) - ).node.fields.nodes; - for (const [fieldName, fieldValue] of Object.entries(fieldsToUpdate)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - if (!field) - if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - if (field.dataType === "DATE") { - valueToSet = { date: String(fieldValue) }; - } else if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) - try { - const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], - updatedField = ( - await github.graphql( - "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", - { fieldId: field.id, fieldName: field.name, options: allOptions } - ) - ).updateProjectV2Field.projectV2Field; - ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); - } catch (createError) { - core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); - continue; - } - if (!option) { - core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } + if (i === createIssueItems.length - 1) { + core.setOutput("issue_number", issue.number); + core.setOutput("issue_url", issue.html_url); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); + core.info("Consider enabling issues in repository settings if you want to create issues automatically"); + continue; } - core.setOutput("item-id", itemId); + core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); + throw error; + } + } + if (createdIssues.length > 0) { + let summaryContent = "\n\n## GitHub Issues\n"; + for (const issue of createdIssues) { + const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; + summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } + await core.summary.addRaw(summaryContent).write(); + } + const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + core.info(`Temporary ID map: ${tempIdMapOutput}`); + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && createdIssues.length > 0) { + const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + core.info(`Successfully created ${createdIssues.length} issue(s)`); + } + (async () => { + await main(); + })(); + + update_cache_memory: + needs: + - agent + - detection + if: always() && needs.detection.outputs.success == 'true' + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Download cache-memory artifact (default) + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + continue-on-error: true + with: + name: cache-memory + path: /tmp/gh-aw/cache-memory + - name: Save cache-memory to cache (default) + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + key: memory-${{ github.workflow }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + + upload_assets: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) + runs-on: ubuntu-slim + permissions: + contents: write + timeout-minutes: 10 + outputs: + branch_name: ${{ steps.upload_assets.outputs.branch_name }} + published_count: ${{ steps.upload_assets.outputs.published_count }} + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Download assets + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: safe-outputs-assets + path: /tmp/gh-aw/safeoutputs/assets/ + - name: List downloaded asset files + continue-on-error: true + run: | + echo "Downloaded asset files:" + ls -la /tmp/gh-aw/safeoutputs/assets/ + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Upload Assets to Orphaned Branch + id: upload_assets + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ASSETS_BRANCH: "assets/${{ github.workflow }}" + GH_AW_ASSETS_MAX_SIZE_KB: 10240 + GH_AW_ASSETS_ALLOWED_EXTS: ".png,.jpg" + GH_AW_WORKFLOW_NAME: "Dev" + GH_AW_ENGINE_ID: "copilot" + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require("fs"); + const path = require("path"); + const crypto = require("crypto"); + const MAX_LOG_CONTENT_LENGTH = 10000; + function truncateForLogging(content) { + if (content.length <= MAX_LOG_CONTENT_LENGTH) { + return content; + } + return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; + } + function loadAgentOutput() { + const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; + if (!agentOutputFile) { + core.info("No GH_AW_AGENT_OUTPUT environment variable found"); + return { success: false }; + } + let outputContent; + try { + outputContent = fs.readFileSync(agentOutputFile, "utf8"); } catch (error) { - if (error.message && error.message.includes("does not have permission to create projects")) { - const usingCustomToken = !!process.env.GH_AW_PROJECT_GITHUB_TOKEN; - core.error( - `Failed to manage project: ${error.message}\n\nTroubleshooting:\n • Create the project manually at https://github.com/orgs/${owner}/projects/new.\n • Or supply a PAT (classic with project + repo scopes, or fine-grained with Projects: Read+Write) via GH_AW_PROJECT_GITHUB_TOKEN.\n • Or use a GitHub App with Projects: Read+Write permission.\n • Ensure the workflow grants projects: write.\n\n` + - (usingCustomToken ? "GH_AW_PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN - this cannot access Projects v2 API. You must configure GH_AW_PROJECT_GITHUB_TOKEN.") - ); - } else core.error(`Failed to manage project: ${error.message}`); - throw error; + const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + return { success: false, error: errorMessage }; + } + if (outputContent.trim() === "") { + core.info("Agent output content is empty"); + return { success: false }; + } + core.info(`Agent output content length: ${outputContent.length}`); + let validatedOutput; + try { + validatedOutput = JSON.parse(outputContent); + } catch (error) { + const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; + core.error(errorMessage); + core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); + return { success: false, error: errorMessage }; + } + if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { + core.info("No valid items found in agent output"); + core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); + return { success: false }; + } + return { success: true, items: validatedOutput.items }; + } + function normalizeBranchName(branchName) { + if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { + return branchName; + } + let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); + normalized = normalized.replace(/-+/g, "-"); + normalized = normalized.replace(/^-+|-+$/g, ""); + if (normalized.length > 128) { + normalized = normalized.substring(0, 128); } + normalized = normalized.replace(/-+$/, ""); + normalized = normalized.toLowerCase(); + return normalized; } async function main() { + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const branchName = process.env.GH_AW_ASSETS_BRANCH; + if (!branchName || typeof branchName !== "string") { + core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); + return; + } + const normalizedBranchName = normalizeBranchName(branchName); + core.info(`Using assets branch: ${normalizedBranchName}`); const result = loadAgentOutput(); - if (!result.success) return; - const updateProjectItems = result.items.filter(item => "update_project" === item.type); - if (0 !== updateProjectItems.length) - for (let i = 0; i < updateProjectItems.length; i++) { - const output = updateProjectItems[i]; + if (!result.success) { + core.setOutput("upload_count", "0"); + core.setOutput("branch_name", normalizedBranchName); + return; + } + const uploadItems = result.items.filter( item => item.type === "upload_asset"); + if (uploadItems.length === 0) { + core.info("No upload-asset items found in agent output"); + core.setOutput("upload_count", "0"); + core.setOutput("branch_name", normalizedBranchName); + return; + } + core.info(`Found ${uploadItems.length} upload-asset item(s)`); + let uploadCount = 0; + let hasChanges = false; + try { + try { + await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); + await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); + core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); + } catch (originError) { + if (!normalizedBranchName.startsWith("assets/")) { + core.setFailed( + `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + + `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + + `Please create the branch manually first, or use a branch name starting with 'assets/'.` + ); + return; + } + core.info(`Creating new orphaned branch: ${normalizedBranchName}`); + await exec.exec(`git checkout --orphan ${normalizedBranchName}`); + await exec.exec(`git rm -rf .`); + await exec.exec(`git clean -fdx`); + } + for (const asset of uploadItems) { try { - await updateProject(output); + const { fileName, sha, size, targetFileName } = asset; + if (!fileName || !sha || !targetFileName) { + core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); + continue; + } + const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); + if (!fs.existsSync(assetSourcePath)) { + core.warning(`Asset file not found: ${assetSourcePath}`); + continue; + } + const fileContent = fs.readFileSync(assetSourcePath); + const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); + if (computedSha !== sha) { + core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); + continue; + } + if (fs.existsSync(targetFileName)) { + core.info(`Asset ${targetFileName} already exists, skipping`); + continue; + } + fs.copyFileSync(assetSourcePath, targetFileName); + await exec.exec(`git add "${targetFileName}"`); + uploadCount++; + hasChanges = true; + core.info(`Added asset: ${targetFileName} (${size} bytes)`); } catch (error) { - (core.error(`Failed to process item ${i + 1}`), logGraphQLError(error, `Processing update_project item ${i + 1}`)); + core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (hasChanges) { + const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; + await exec.exec(`git`, [`commit`, `-m`, commitMessage]); + if (isStaged) { + core.summary.addRaw("## Staged Asset Publication"); + } else { + await exec.exec(`git push origin ${normalizedBranchName}`); + core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); + core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); + } + for (const asset of uploadItems) { + if (asset.fileName && asset.sha && asset.size && asset.url) { + core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); + } } + core.summary.write(); + } else { + core.info("No new assets to upload"); } + } catch (error) { + core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); + return; + } + core.setOutput("upload_count", uploadCount.toString()); + core.setOutput("branch_name", normalizedBranchName); } - ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main()); + await main(); diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md index 215628a0078..b5efd5d9f0d 100644 --- a/.github/workflows/dev.md +++ b/.github/workflows/dev.md @@ -1,28 +1,48 @@ --- -name: Dev -description: Update issues on an org-owned Project Board -on: +on: workflow_dispatch: +name: Dev +description: Test upload-asset with Python graph generation +timeout-minutes: 5 +strict: true +engine: copilot permissions: contents: read issues: read - pull-requests: read + +sandbox: + mcp: + port: 8080 tools: github: - toolsets: [default, projects] - github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} # fine-grained PAT with scopes Organizational: `Projects: Read & Write` and `Metadata: Read` and `Issues: Read & Write` + toolsets: [issues] safe-outputs: - update-project: - github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} ---- + upload-asset: + allowed-exts: [".png", ".jpg"] + max: 5 + create-issue: + title-prefix: "[Dev Test] " + max: 1 -# Issue Updater +imports: + - shared/gh.md + - shared/python-dataviz.md +--- -Goal: prove we can **update a Project item** that points to a real GitHub Issue. +# Test Upload Asset with Python Graph Generation -Project board: +Create a dummy graph using Python and matplotlib, then upload it as an asset. -Task: Update all issue items that are currently on the project board with Status "In Progress". +**Requirements:** +1. Use Python to create a simple graph (e.g., a sine wave or bar chart) using matplotlib +2. Save the graph as a PNG file to /tmp/graph.png +3. Use the `upload_asset` tool to upload the graph +4. The tool should return a URL where the graph can be accessed +5. Create an issue that includes the graph using markdown image syntax +6. Verify that: + - The graph file was created successfully + - The asset was uploaded and a URL was returned + - The issue was created with the embedded graph image diff --git a/.github/workflows/playground-org-project-update-issue.lock.yml b/.github/workflows/playground-org-project-update-issue.lock.yml index d0d399e231d..44fb3642843 100644 --- a/.github/workflows/playground-org-project-update-issue.lock.yml +++ b/.github/workflows/playground-org-project-update-issue.lock.yml @@ -20,10 +20,6 @@ # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # # Update issues on an org-owned Project Board -# -# Resolved workflow manifest: -# Imports: -# - shared/safe-output-app.md name: "Playground: Org project update issue" "on": @@ -1828,7 +1824,7 @@ jobs: - name: Setup MCPs env: - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} run: | mkdir -p /tmp/gh-aw/mcp-config @@ -1977,8 +1973,6 @@ jobs: PROMPT_DIR="$(dirname "$GH_AW_PROMPT")" mkdir -p "$PROMPT_DIR" cat << 'PROMPT_EOF' > "$GH_AW_PROMPT" - - # Issue Updater Goal: prove we can **update a Project item** that points to a real GitHub Issue. @@ -2336,7 +2330,7 @@ jobs: GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.TEST_ORG_PROJECT_WRITE }} GITHUB_REF_NAME: ${{ github.ref_name }} GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }} GITHUB_WORKSPACE: ${{ github.workspace }} @@ -2452,11 +2446,12 @@ jobs: } await main(); env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,TEST_ORG_PROJECT_WRITE' SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SECRET_TEST_ORG_PROJECT_WRITE: ${{ secrets.TEST_ORG_PROJECT_WRITE }} - name: Upload Safe Outputs if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 @@ -5617,18 +5612,6 @@ jobs: tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} total_count: ${{ steps.missing_tool.outputs.total_count }} steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - github-api-url: ${{ github.api_url }} - permission-contents: read - permission-issues: write - permission-pull-requests: write - name: Debug job inputs env: COMMENT_ID: ${{ needs.activation.outputs.comment_id }} @@ -5659,7 +5642,7 @@ jobs: GH_AW_NOOP_MAX: 1 GH_AW_WORKFLOW_NAME: "Playground: Org project update issue" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const fs = require("fs"); const MAX_LOG_CONTENT_LENGTH = 10000; @@ -5751,7 +5734,7 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Playground: Org project update issue" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | async function main() { const fs = require("fs"); @@ -5864,7 +5847,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | const fs = require("fs"); const MAX_LOG_CONTENT_LENGTH = 10000; @@ -6114,19 +6097,6 @@ jobs: main().catch(error => { core.setFailed(error instanceof Error ? error.message : String(error)); }); - - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - echo "Revoking GitHub App installation token..." - # GitHub CLI will auth with the token being revoked. - gh api \ - --method DELETE \ - -H "Authorization: token $TOKEN" \ - /installation/token || echo "Token revoke may already be expired." - - echo "Token invalidation step complete." detection: needs: agent @@ -6413,17 +6383,6 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ github.event.repository.name }} - github-api-url: ${{ github.api_url }} - permission-contents: read - permission-organization-projects: write - name: Setup JavaScript files id: setup_scripts shell: bash @@ -6529,7 +6488,7 @@ jobs: env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} script: | globalThis.github = github; globalThis.context = context; @@ -6953,17 +6912,4 @@ jobs: } } ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main()); - - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' - env: - TOKEN: ${{ steps.app-token.outputs.token }} - run: | - echo "Revoking GitHub App installation token..." - # GitHub CLI will auth with the token being revoked. - gh api \ - --method DELETE \ - -H "Authorization: token $TOKEN" \ - /installation/token || echo "Token revoke may already be expired." - - echo "Token invalidation step complete." diff --git a/.github/workflows/playground-org-project-update-issue.md b/.github/workflows/playground-org-project-update-issue.md index 46b5191247a..6f298fc0c37 100644 --- a/.github/workflows/playground-org-project-update-issue.md +++ b/.github/workflows/playground-org-project-update-issue.md @@ -12,13 +12,11 @@ permissions: tools: github: toolsets: [default, projects] - -imports: - - shared/safe-output-app.md + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} # fine-grained PAT with scopes Organizational: `Projects: Read & Write` and `Metadata: Read` and `Issues: Read & Write` safe-outputs: - update-project: {} - + update-project: + github-token: ${{ secrets.TEST_ORG_PROJECT_WRITE }} --- # Issue Updater