diff --git a/.github/instructions/action-yaml.instructions.md b/.github/instructions/action-yaml.instructions.md index b6724e82e93..1860a58d297 100644 --- a/.github/instructions/action-yaml.instructions.md +++ b/.github/instructions/action-yaml.instructions.md @@ -19,7 +19,7 @@ Programmatic access: `#actionSchema()`, `#actionSpelFunctions()` (available insi ## SpEL Expression Scope -- **`cli.options::default`** — Only `ActionSpelFunctions` + `#env()`; NO action context, NO product-specific functions +- **`cli.options::default`** — Only `ActionSpelFunctions` + `#env()` + `#.env` (e.g. `#ado.env`, `#github.env`); NO action context, NO product-specific functions - **`steps` section** — Full access: all SpEL functions, action variables, product-specific (`fod.*`, `ssc.*`), CI-specific (`github.*`, `gitlab.*`, `ado.*`) ## YAML/SpEL Pitfalls diff --git a/.github/skills/fcli-action-yaml-reference/SKILL.md b/.github/skills/fcli-action-yaml-reference/SKILL.md index 1fb39569e81..3aace9d24cd 100644 --- a/.github/skills/fcli-action-yaml-reference/SKILL.md +++ b/.github/skills/fcli-action-yaml-reference/SKILL.md @@ -13,7 +13,7 @@ Detailed reference material for developing fcli action YAML files. The always-on Default values for CLI options — evaluated **before** action steps run. -- Only `ActionSpelFunctions` + `#env()` available +- Only `ActionSpelFunctions` + `#env()` + `#.env` (e.g. `#ado.env`, `#github.env`) available - Evaluated via `ActionRunnerConfig.getSpelEvaluator()` - **NOT available:** `ActionRunnerContextSpelFunctions` (`action.*`), product-specific functions (`fod.*`, `ssc.*`), action variables, execution context diff --git a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml index 416c26b9b94..5644e08c5f7 100644 --- a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml +++ b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/detect-env.yaml @@ -6,7 +6,7 @@ usage: description: | This action collects data about the current environment in which fcli is being run, like the current CI/CD platform (GitHub, GitLab, Azure DevOps, ...), Git data from - current source code directory, ... Collected data is stored in global.ci.* action + current source code directory, ... Collected data is stored in global.ci.* action variables for use by other actions, and printed to the output for user reference. This includes both environment-specific data, and any data derived from that data, like default FoD release name and SSC application version name. @@ -14,7 +14,7 @@ usage: config: output: immediate mcp: exclude - + steps: # Only run if not run before; global.isCiInitialized is set at the end of this action. # Callers may also check this variable to avoid re-running this action. @@ -26,9 +26,9 @@ steps: global.ci.fcliVersion: ${fcliBuildProps.fcliVersion} # Fcli version global.ci.fcliBuildInfo: ${fcliBuildProps.fcliBuildInfo} # Fcli build information global.ci.name: # Name of current CI system - global.ci.id: # Id of current CI system, used to look up -* actions + global.ci.id: # Id of current CI system, used to look up -* actions global.ci.qualifiedRepoName: # Fully qualified repository name - global.ci.sourceBranch: # The current branch being processed/scanned + global.ci.sourceBranch: # The current branch being processed/scanned global.ci.commitHeadSHA: # Head commit SHA (actual commit on branch) global.ci.commitMergeSHA: # Merge commit SHA (for PRs on GitHub, same as head otherwise) global.ci.workspaceDir: "." # Workspace/repository root directory (default to current dir) @@ -38,6 +38,7 @@ steps: global.ci.prId: # Pull/merge request numeric identifier (null if not active) global.ci.prTarget: # Pull/merge request target branch (null if not active) global.ci.prTerminology: "Pull Request" # Pull/merge request terminology for this CI system (default) + global.ci.prKeyword: # Keyword for PR actions: 'pr' for GitHub/ADO, 'mr' for GitLab # The following are set by default at the end, but may be overridden by individual CI configurations global.fod.prCommentAction: # FoD PR comment action global.ssc.prCommentAction: # SSC PR comment action @@ -52,7 +53,7 @@ steps: ci.detected: ${#_ci.detect()} ci.type: ${ci.detected.type} ci.env: ${ci.detected.env} - + # For recognized CI systems (not unknown), extract properties from detected environment # Using conditional navigation operator ?. to safely access properties even if ci.env is empty - if: ${ci.type!='unknown'} @@ -69,12 +70,13 @@ steps: global.ci.prId: ${ci.env?.pullRequest?.id} global.ci.prTarget: ${ci.env?.pullRequest?.target} global.ci.prTerminology: ${ci.env?.prTerminology?:global.ci.prTerminology} - + global.ci.prKeyword: ${ci.env?.prKeyword?:global.ci.prKeyword} + # GitHub-specific properties - if: ${ci.type=='github'} var.set: global.ci.jobSummaryFile: ${#ifBlank(global.ci.jobSummaryFile,ci.env?.jobSummaryFile)} - + # Jenkins - if: ${#isNotBlank(#env('JENKINS_HOME'))||#isNotBlank(#env('JENKINS_URL'))} var.set: @@ -82,7 +84,7 @@ steps: global.ci.id: jenkins global.ci.workspaceDir: ${#env('WORKSPACE')} global.ci.sourceDir: ${#env('WORKSPACE')} - + # Override sourceDir with SOURCE_DIR if specified (custom user variable) # NOTE: workspaceDir is NOT overridden by SOURCE_DIR, as it should always be the workspace root - if: ${#isNotBlank(#env('SOURCE_DIR'))} @@ -93,8 +95,8 @@ steps: global.ci.workspaceDir: ${#ifBlank(global.ci.workspaceDir,'.')} global.ci.sourceDir: ${#ifBlank(global.ci.sourceDir,'.')} - var.set: - global.ci.localRepo: ${#localRepo(global.ci.sourceDir)} - + global.ci.localRepo: ${#git.localRepo(global.ci.sourceDir)} + # Generic local repository fallback (run if previous CI-specific steps didn't set these) - if: ${#isBlank(global.ci.id) && global.ci.localRepo!=null} var.set: @@ -104,31 +106,36 @@ steps: global.ci.sourceBranch: ${global.ci.localRepo.branch?.short} global.ci.commitHeadSHA: ${global.ci.localRepo.commit?.headId?.full} global.ci.commitMergeSHA: ${global.ci.localRepo.commit?.mergeId?.full} - + # Additional generic variables based on the output of the CI-specific sections above - var.set: global.ci.defaultFortifyRepo: ${#joinOrNull(':', global.ci.qualifiedRepoName, global.ci.sourceBranch)} - # Set default reporting actions based on ci identifier. Note that FoD/SSC CI actions should check existence of these actions + # Set default reporting actions based on ci identifier. Note that FoD/SSC CI actions should check existence of these actions # TODO Only use default values if not explicitly defined in CI-specific sections above. - global.ci.fod_prCommentAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'pr-comment'))} - global.ci.ssc_prCommentAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'pr-comment'))} + global.ci.fod_prCommentAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, global.ci.prKeyword, 'comment'))} + global.ci.ssc_prCommentAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, global.ci.prKeyword, 'comment'))} global.ci.fod_sastExportAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'sast-report'))} global.ci.ssc_sastExportAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'sast-report'))} global.ci.fod_dastExportAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'dast-report'))} global.ci.ssc_dastExportAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'dast-report'))} global.ci.fod_scaExportAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'debricked-report'))} # TODO 'debricked' or more generic 'sca' or 'composition (analysis)' global.ci.ssc_scaExportAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'debricked-report'))} # TODO 'debricked' or more generic 'sca' or 'composition (analysis)' + # Aviator remediations action: use a CI-specific -remediations- action if it exists + # (e.g. github-remediations-pr for GitHub PR creation), otherwise fall back to the generic + # push-remediations action that only pushes changes to a new branch. + global.ci.fod_aviatorRemediationsAction: ${#actionOrNull('fod',#joinOrNull('-', global.ci.id, 'remediations', global.ci.prKeyword))?:'push-remediations'} + global.ci.ssc_aviatorRemediationsAction: ${#actionOrNull('ssc',#joinOrNull('-', global.ci.id, 'remediations', global.ci.prKeyword))?:'push-remediations'} # Set PR-related skip reason if not active - if: "${global.ci.prActive!=true}" var.set: global.ci.prNotActiveSkipReason: "Not a ${global.ci.prTerminology}" - - log.info: "${global.ci.name!=null ? 'Detected '+global.ci.name : 'No CI system detected'}" + - log.info: {msg: "${global.ci.name!=null ? 'Detected '+global.ci.name : 'No CI system detected'}"} - records.for-each: from: ${#properties(global.ci)} record.var-name: p do: - if: ${#isDebugEnabled() || p.value!=null} - log.info: "${' '+p.key+': '+p.value}" + log.info: {msg: "${' '+p.key+': '+p.value}"} # Mark as initialized to prevent re-running this action - var.set: diff --git a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties index a669640628d..30b5794df96 100644 --- a/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties +++ b/fcli-core/fcli-ai-assist/src/main/resources/com/fortify/cli/ai_assist/i18n/AiAssistMessages.properties @@ -1,9 +1,9 @@ -fcli.ai-assist.usage.header = (PREVIEW) Manage AI assistant integrations +fcli.ai-assist.usage.header = Manage AI assistant integrations fcli.ai-assist.usage.description = Manage AI-related functionality like MCP servers and skills. # fcli ai-assist extensions fcli.ai-assist.extensions.definitions.note = NOTE: Available extension versions, download URLs, and assistant detection logic are managed through fcli tool definitions (`ai-assistant-extensions*` at https://github.com/fortify/tool-definitions/tree/main/v1). Run `fcli tool definitions update` before running this command. -fcli.ai-assist.extensions.usage.header = (PREVIEW) Manage Fortify extensions for AI coding assistants +fcli.ai-assist.extensions.usage.header = Manage Fortify extensions for AI coding assistants fcli.ai-assist.extensions.usage.description = Set up, uninstall, or check status of Fortify extensions (skills, agents, plugins) for AI coding assistants like Claude Code, GitHub Copilot, OpenAI Codex, and Gemini CLI.%n\ %n${fcli.ai-assist.extensions.definitions.note} fcli.ai-assist.extensions.setup.usage.header = Set up Fortify extensions for coding assistants @@ -60,10 +60,10 @@ fcli.ai-assist.extensions.detect = Run detection checks (glob patterns, command # fcli ai-assist mcp fcli.ai-assist.mcp.skills.note = NOTE: For better user experience and potentially lower token usage, consider Fortify skills from https://github.com/fortify/skills, which can be installed through your AI assistant marketplace (if available) or by utilizing the `fcli ai-assist extensions' commands. -fcli.ai-assist.mcp.usage.header = (PREVIEW) Manage fcli MCP server commands for AI assistants +fcli.ai-assist.mcp.usage.header = Manage fcli MCP server commands for AI assistants fcli.ai-assist.mcp.usage.description = Start fcli MCP servers for AI assistants.%n\ %n${fcli.ai-assist.mcp.skills.note} -fcli.ai-assist.mcp.start-stdio.usage.header = (PREVIEW) Start fcli stdio MCP server. +fcli.ai-assist.mcp.start-stdio.usage.header = Start fcli stdio MCP server. fcli.ai-assist.mcp.start-stdio.usage.description = Start the fcli MCP server over stdio. This command exposes fcli module commands and/or \ imported action functions as MCP tools to AI clients.%n\ %n${fcli.ai-assist.mcp.skills.note}%n\ @@ -82,7 +82,7 @@ fcli.ai-assist.mcp.start-stdio.job-safe-return = Maximum time to wait synchronou fcli.ai-assist.mcp.start-stdio.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). fcli.ai-assist.mcp.start-stdio.async-bg-threads = Number of background threads for running async streaming jobs. Default: 2. -fcli.ai-assist.mcp.start-http.usage.header = (PREVIEW) Start fcli HTTP MCP server. +fcli.ai-assist.mcp.start-http.usage.header = Start fcli HTTP MCP server. fcli.ai-assist.mcp.start-http.usage.description = Start an HTTP MCP server exposing only exported functions from imported action YAML files defined in a config file. Generate a sample config file with 'fcli ai-assist mcp create-http-config --type ' and customize the generated YAML for your environment. The server listens for MCP POST requests on the /mcp endpoint. Each request must include the product-specific auth header as semicolon-separated key=value pairs; escape literal '\\', ';', or '=' characters as '\\\\', '\\;', or '\\='.\ %n%n${fcli.ai-assist.mcp.skills.note}\ %n%nNOTE: The HTTP MCP server exposes a larger attack surface than local stdio MCP server; use HTTP MCP server only when needed and prefer HTTPS with properly configured certificates.\ diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json index 15a37b3ea4d..f055ce35015 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jgit/reflect-config.json @@ -7,9 +7,7 @@ "methods": [ { "name": "", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -28,9 +26,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -42,9 +38,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -56,9 +50,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -70,9 +62,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -84,9 +74,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -98,9 +86,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -112,9 +98,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -126,9 +110,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -140,9 +122,7 @@ "methods": [ { "name": "values", - "parameterTypes": [ - - ] + "parameterTypes": [] } ] }, @@ -154,248 +134,492 @@ "methods": [ { "name": "values", + "parameterTypes": [] + } + ] + }, + { + "name": "com.github.chirontt.gitserver.LfsBatchServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", "parameterTypes": [ - + "org.eclipse.jgit.lfs.server.fs.FileLfsRepository", + "java.nio.file.Path" ] } ] }, { - "name":"com.github.chirontt.gitserver.LfsBatchServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":["org.eclipse.jgit.lfs.server.fs.FileLfsRepository","java.nio.file.Path"] }] + "name": "com.github.chirontt.lfs.server.LfsProtocolServletV2$LfsRequestV2", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.LfsProtocolServletV2$LfsRequestV2", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.LfsRef", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.LfsRef", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$CreateLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$CreateLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$DeleteLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$DeleteLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$ListLocksToVerify", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingRequest$ListLocksToVerify", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$CreatedOrDeletedLock", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock" + ] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$CreatedOrDeletedLock", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock"] } + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Error", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock", + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LockExistsError", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String", + "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock" + ] + } + ] + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Locks", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.util.List", + "java.lang.String" + ] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Error", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":["java.lang.String"] }] + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LocksToVerify", + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Owner", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "com.github.chirontt.lfs.server.locks.lm.PersistentLock", + "allDeclaredFields": true, + "allDeclaredMethods": true }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LockExistsError", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":["java.lang.String","com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Lock"] }] + "name": "com.github.chirontt.lfs.server.locks.internal.LfsFileLockingText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Locks", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["java.util.List","java.lang.String"] } + "name": "org.eclipse.jgit.diff.DiffAlgorithm$SupportedAlgorithm", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$LocksToVerify", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "org.eclipse.jgit.dircache.DirCache$DirCacheVersion", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.LfsFileLockingResponse$Owner", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[ - {"name":"","parameterTypes":[] }, - {"name":"","parameterTypes":["java.lang.String"] } + "name": "org.eclipse.jgit.gitrepo.internal.RepoText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } ] }, { - "name":"com.github.chirontt.lfs.server.locks.lm.PersistentLock", - "allDeclaredFields":true, - "allDeclaredMethods":true + "name": "org.eclipse.jgit.http.server.GitServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"com.github.chirontt.lfs.server.locks.internal.LfsFileLockingText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.http.server.HttpServerText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.diff.DiffAlgorithm$SupportedAlgorithm", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.internal.JGitText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.dircache.DirCache$DirCacheVersion", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.internal.storage.dfs.DfsText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.gitrepo.internal.RepoText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.internal.LfsText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.http.server.GitServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.LfsObject", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.http.server.HttpServerText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.LfsProtocolServlet$LfsRequest", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.internal.JGitText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Action", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.internal.storage.dfs.DfsText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Body", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.internal.LfsText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$Error", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.server.LfsObject", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.Response$ObjectInfo", + "allDeclaredFields": true }, { - "name":"org.eclipse.jgit.lfs.server.LfsProtocolServlet$LfsRequest", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lfs.server.fs.FileLfsServlet", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [ + "org.eclipse.jgit.lfs.server.fs.FileLfsRepository", + "long" + ] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Action", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lfs.server.internal.LfsServerText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Body", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$AutoCRLF", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$Error", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$CheckStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.Response$ObjectInfo", - "allDeclaredFields":true + "name": "org.eclipse.jgit.lib.CoreConfig$EOL", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.fs.FileLfsServlet", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"","parameterTypes":["org.eclipse.jgit.lfs.server.fs.FileLfsRepository","long"] }] + "name": "org.eclipse.jgit.lib.CoreConfig$HideDotFiles", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lfs.server.internal.LfsServerText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$LogRefUpdates", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$AutoCRLF", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$SymLinks", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$CheckStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustLooseRefStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$EOL", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustPackedRefsStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$HideDotFiles", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.CoreConfig$TrustStat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$LogRefUpdates", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "allPublicMethods":true, - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GpgConfig$GpgFormat", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$SymLinks", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.transport.HttpConfig$HttpRedirectMode", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustLooseRefStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.transport.http.apache.internal.HttpApacheText", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustPackedRefsStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$PackRefsMode", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.CoreConfig$TrustStat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$AggressiveWindow", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.lib.GpgConfig$GpgFormat", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$Autodetach", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.transport.HttpConfig$HttpRedirectMode", - "methods":[{"name":"values","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$Prune", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] }, { - "name":"org.eclipse.jgit.transport.http.apache.internal.HttpApacheText", - "allDeclaredFields":true, - "allDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }] + "name": "org.eclipse.jgit.lib.GcConfig$LogExpire", + "methods": [ + { + "name": "values", + "parameterTypes": [] + } + ] } -] +] \ No newline at end of file diff --git a/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml b/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml index 2e29abbd104..a3d3f360c6d 100644 --- a/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml +++ b/fcli-core/fcli-app/src/main/resources/com/fortify/cli/app/actions/build-time/ci-doc.yaml @@ -256,10 +256,11 @@ formatters: desc: >- (PREVIEW) If `DO_PR_COMMENT` is set to true (implied if any of the other two `PR_COMMENT_*` variables are set), a Pull Request or Merge Request comment will be generated using an fcli-provided action - matching the current CI system like actionRef:_:github-pr-comment or, if specified, the custom fcli action - specified through `PR_COMMENT_ACTION`. Extra options for the fcli action can be specified through - the `PR_COMMENT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned - custom actions to be used. + matching the current CI system like actionRef:_:github-pr-comment, actionRef:_:ado-pr-comment, or + actionRef:_:gitlab-mr-comment. + A custom action can be specified through `PR_COMMENT_ACTION`. Extra options for the fcli action + can be specified through the `PR_COMMENT_EXTRA_OPTS` environment variable, which may include + fcli options to allow unsigned custom actions to be used. - names: DO_SAST_EXPORT\nSAST_EXPORT_ACTION\nSAST_EXPORT_EXTRA_OPTS desc: >- If `DO_SAST_EXPORT` is not set to `false` and a SAST scan was completed, the SAST vulnerability @@ -268,6 +269,18 @@ formatters: specified through `SAST_EXPORT_ACTION`. Extra options for the fcli action can be specified through the `SAST_EXPORT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned custom actions to be used. + - names: DO_AVIATOR_REMEDIATIONS\nAVIATOR_REMEDIATIONS_ACTION\nAVIATOR_REMEDIATIONS_EXTRA_OPTS\nGIT_PUSH_TOKEN + desc: >- + (PREVIEW) If `DO_AVIATOR_REMEDIATIONS` is set to true (implied if any of the other two + `AVIATOR_REMEDIATIONS_*` variables are set) and Aviator remediations are available, those remediations + will be applied to the local source code, committed, and pushed to a new branch. On GitHub, a Pull Request + is created from that branch using the fcli-provided actionRef:_:github-remediations-pr action; on other CI + systems the fcli-provided actionRef:_:push-remediations action is used to push the branch without creating a + Pull or Merge Request. A custom action can be specified through `AVIATOR_REMEDIATIONS_ACTION`, and extra + options for the fcli action can be specified through the `AVIATOR_REMEDIATIONS_EXTRA_OPTS` environment + variable, which may include fcli options to allow unsigned custom actions to be used. The branch push is + authenticated using the `GIT_PUSH_TOKEN` environment variable if set, falling back to the access token of + the current CI system and then to the local Git configuration. ssc: - title: Authentication & Connection fragment: session @@ -413,10 +426,11 @@ formatters: desc: >- (PREVIEW) If `DO_PR_COMMENT` is set to true (implied if any of the other two `PR_COMMENT_*` variables are set), a Pull Request or Merge Request comment will be generated using an fcli-provided action - matching the current CI system like actionRef:_:github-pr-comment or, if specified, the custom fcli action - specified through `PR_COMMENT_ACTION`. Extra options for the fcli action can be specified through - the `PR_COMMENT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned - custom actions to be used. + matching the current CI system like actionRef:_:github-pr-comment, actionRef:_:ado-pr-comment, or + actionRef:_:gitlab-mr-comment. + A custom action can be specified through `PR_COMMENT_ACTION`. Extra options for the fcli action + can be specified through the `PR_COMMENT_EXTRA_OPTS` environment variable, which may include + fcli options to allow unsigned custom actions to be used. - names: DO_SAST_EXPORT\nSAST_EXPORT_ACTION\nSAST_EXPORT_EXTRA_OPTS desc: >- If `DO_SAST_EXPORT` is not set to `false` and a SAST scan was completed, the SAST vulnerability @@ -433,6 +447,18 @@ formatters: specified through `DEBRICKED_EXPORT_ACTION`. Extra options for the fcli action can be specified through the `DEBRICKED_EXPORT_EXTRA_OPTS` environment variable, which may include fcli options to allow unsigned custom actions to be used. + - names: DO_AVIATOR_REMEDIATIONS\nAVIATOR_REMEDIATIONS_ACTION\nAVIATOR_REMEDIATIONS_EXTRA_OPTS\nGIT_PUSH_TOKEN + desc: >- + (PREVIEW) If `DO_AVIATOR_REMEDIATIONS` is set to true (implied if any of the other two + `AVIATOR_REMEDIATIONS_*` variables are set) and Aviator remediations are available, those remediations + will be applied to the local source code, committed, and pushed to a new branch. On GitHub, a Pull Request + is created from that branch using the fcli-provided actionRef:_:github-remediations-pr action; on other CI + systems the fcli-provided actionRef:_:push-remediations action is used to push the branch without creating a + Pull or Merge Request. A custom action can be specified through `AVIATOR_REMEDIATIONS_ACTION`, and extra + options for the fcli action can be specified through the `AVIATOR_REMEDIATIONS_EXTRA_OPTS` environment + variable, which may include fcli options to allow unsigned custom actions to be used. The branch push is + authenticated using the `GIT_PUSH_TOKEN` environment variable if set, falling back to the access token of + the current CI system and then to the local Git configuration. # ============================================================================ # CI SYSTEM DEFINITIONS diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java index 19dff313cdf..c97043572dd 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ActionLoaderHelper.java @@ -22,12 +22,15 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; import java.util.regex.Pattern; +import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -72,7 +75,16 @@ public class ActionLoaderHelper { private static final Logger LOG = LoggerFactory.getLogger(ActionLoaderHelper.class); + private static final Map> builtinActionNamesByModule = new ConcurrentHashMap<>(); private ActionLoaderHelper() {} + + public static final boolean hasBuiltInAction(String moduleName, String actionName) { + if (StringUtils.isBlank(actionName)) { return false; } + return builtinActionNamesByModule + .computeIfAbsent(moduleName, m -> streamAsNames(ActionSource.defaultActionSources(m), ActionValidationHandler.IGNORE) + .collect(Collectors.toSet())) + .contains(actionName); + } public static final ActionLoadResult load(List sources, String name, ActionValidationHandler actionValidationHandler) { return new ActionLoader(sources, actionValidationHandler).load(name); diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java index b215feca546..2b71e8053d9 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctions.java @@ -73,6 +73,7 @@ public IActionSpelFunctions detect() { return ActionUnknownCiSpelFunctions.INSTANCE; } + /** * Unknown/unsupported CI system implementation. * Used when no known CI system is detected. diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistry.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistry.java new file mode 100644 index 00000000000..5d01032cf1b --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistry.java @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci; + +import org.springframework.expression.spel.support.SimpleEvaluationContext; + +import com.fortify.cli.common.action.helper.ci.ado.ActionAdoCiInfoSpelFunctions; +import com.fortify.cli.common.action.helper.ci.ado.ActionAdoSpelFunctions; +import com.fortify.cli.common.action.helper.ci.bitbucket.ActionBitbucketCiInfoSpelFunctions; +import com.fortify.cli.common.action.helper.ci.bitbucket.ActionBitbucketSpelFunctions; +import com.fortify.cli.common.action.helper.ci.github.ActionGitHubCiInfoSpelFunctions; +import com.fortify.cli.common.action.helper.ci.github.ActionGitHubSpelFunctions; +import com.fortify.cli.common.action.helper.ci.gitlab.ActionGitLabCiInfoSpelFunctions; +import com.fortify.cli.common.action.helper.ci.gitlab.ActionGitLabSpelFunctions; +import com.fortify.cli.common.action.runner.ActionRunnerContextLocal; + +public final class ActionCiSpelFunctionsRegistry { + // IMPORTANT: If CI systems are added/removed here, update SpelFunctionDescriptorsFactory as well. + private ActionCiSpelFunctionsRegistry() {} + + public static IActionSpelFunctions[] createInfoSpelFunctions() { + return new IActionSpelFunctions[] { + new ActionGitHubCiInfoSpelFunctions(), + new ActionGitLabCiInfoSpelFunctions(), + new ActionAdoCiInfoSpelFunctions(), + new ActionBitbucketCiInfoSpelFunctions() + }; + } + + public static IActionSpelFunctions[] createRuntimeSpelFunctions(ActionRunnerContextLocal ctx) { + return new IActionSpelFunctions[] { + new ActionGitHubSpelFunctions(ctx), + new ActionGitLabSpelFunctions(ctx), + new ActionAdoSpelFunctions(ctx), + new ActionBitbucketSpelFunctions(ctx) + }; + } + + public static void registerInfoVariables(SimpleEvaluationContext spelContext) { + registerCiVariables(spelContext, createInfoSpelFunctions()); + } + + public static void registerRuntimeVariables(SimpleEvaluationContext spelContext, ActionRunnerContextLocal ctx) { + registerCiVariables(spelContext, createRuntimeSpelFunctions(ctx)); + } + + private static void registerCiVariables(SimpleEvaluationContext spelContext, IActionSpelFunctions[] ciSpelFunctions) { + spelContext.setVariable("_ci", new ActionCiSpelFunctions(ciSpelFunctions)); + for ( var ciSpelFunctionsEntry : ciSpelFunctions ) { + spelContext.setVariable(ciSpelFunctionsEntry.getType(), ciSpelFunctionsEntry); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoCiInfoSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoCiInfoSpelFunctions.java new file mode 100644 index 00000000000..7d0499c3103 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoCiInfoSpelFunctions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci.ado; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.ci; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; +import com.fortify.cli.common.ci.ado.AdoEnvironment; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; + +@Reflectable +@SpelFunctionPrefix("ado.") +public class ActionAdoCiInfoSpelFunctions implements IActionSpelFunctions { + protected final AdoEnvironment env; + + public ActionAdoCiInfoSpelFunctions() { + this(AdoEnvironment.detect()); + } + + protected ActionAdoCiInfoSpelFunctions(AdoEnvironment env) { + this.env = env; + } + + @SpelFunction(cat=ci, desc="Returns Azure DevOps environment data as ObjectNode (auto-detected for the current pipeline run)", + returns="Environment data or `null` if not running in Azure DevOps", + returnType=AdoEnvironment.class) + @Override + public ObjectNode getEnv() { + return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; + } + + @SpelFunction(cat=ci, desc="Returns CI system type identifier", + returns="\"ado\"") + @Override + public String getType() { + return AdoEnvironment.TYPE; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoSpelFunctions.java index 29642eda83e..d22b3d006ed 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/ado/ActionAdoSpelFunctions.java @@ -16,21 +16,15 @@ import org.apache.commons.lang3.StringUtils; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; -import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; import com.fortify.cli.common.action.runner.ActionRunnerContextLocal; -import com.fortify.cli.common.ci.ado.AdoEnvironment; import com.fortify.cli.common.ci.ado.AdoRestHelper; import com.fortify.cli.common.ci.ado.AdoUnirestInstanceSupplier; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.spel.fn.descriptor.annotation.RenderSubFunctionsMode; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; -import lombok.RequiredArgsConstructor; - /** * Action-friendly Azure DevOps helper providing convenient methods for CI/CD workflows. * Automatically detects Azure DevOps environment and provides both high-level @@ -41,11 +35,9 @@ * @author rsenden */ @Reflectable -@RequiredArgsConstructor @SpelFunctionPrefix("ado.") -public class ActionAdoSpelFunctions implements IActionSpelFunctions { +public class ActionAdoSpelFunctions extends ActionAdoCiInfoSpelFunctions { private final ActionRunnerContextLocal ctx; - private final AdoEnvironment env; private AdoRestHelper restHelper; /** @@ -53,31 +45,8 @@ public class ActionAdoSpelFunctions implements IActionSpelFunctions { * Does not throw if not in CI - use getEnv() != null to check. */ public ActionAdoSpelFunctions(ActionRunnerContextLocal ctx) { + super(); this.ctx = ctx; - this.env = AdoEnvironment.detect(); - } - - /** - * Get environment data as ObjectNode for use in actions. - * Returns null if not running in Azure DevOps. - * Can be accessed in action YAML as: ${#ci.ado().env} - */ - @SpelFunction(cat=ci, desc="Returns Azure DevOps environment data as ObjectNode (auto-detected for the current pipeline run)", - returns="Environment data or `null` if not running in Azure DevOps", - returnType=AdoEnvironment.class) - @Override - public ObjectNode getEnv() { - return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; - } - - /** - * Returns "ado" as the CI system type. - */ - @SpelFunction(cat=ci, desc="Returns CI system type identifier", - returns="\"ado\"") - @Override - public String getType() { - return AdoEnvironment.TYPE; } /** diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketCiInfoSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketCiInfoSpelFunctions.java new file mode 100644 index 00000000000..d04868a61fb --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketCiInfoSpelFunctions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci.bitbucket; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.ci; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; +import com.fortify.cli.common.ci.bitbucket.BitbucketEnvironment; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; + +@Reflectable +@SpelFunctionPrefix("bitbucket.") +public class ActionBitbucketCiInfoSpelFunctions implements IActionSpelFunctions { + protected final BitbucketEnvironment env; + + public ActionBitbucketCiInfoSpelFunctions() { + this(BitbucketEnvironment.detect()); + } + + protected ActionBitbucketCiInfoSpelFunctions(BitbucketEnvironment env) { + this.env = env; + } + + @SpelFunction(cat=ci, desc="Returns Bitbucket Pipelines environment data as ObjectNode (auto-detected for the current step)", + returns="Environment data or `null` if not running in Bitbucket Pipelines", + returnType=BitbucketEnvironment.class) + @Override + public ObjectNode getEnv() { + return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; + } + + @SpelFunction(cat=ci, desc="Returns CI system type identifier", + returns="\"bitbucket\"") + @Override + public String getType() { + return BitbucketEnvironment.TYPE; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketSpelFunctions.java index 6c7753d0f38..6f6a91e4ca3 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/bitbucket/ActionBitbucketSpelFunctions.java @@ -16,21 +16,15 @@ import org.apache.commons.lang3.StringUtils; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; -import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; import com.fortify.cli.common.action.runner.ActionRunnerContextLocal; -import com.fortify.cli.common.ci.bitbucket.BitbucketEnvironment; import com.fortify.cli.common.ci.bitbucket.BitbucketRestHelper; import com.fortify.cli.common.ci.bitbucket.BitbucketUnirestInstanceSupplier; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.spel.fn.descriptor.annotation.RenderSubFunctionsMode; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; -import lombok.RequiredArgsConstructor; - /** * Bitbucket-specific helper exposed to actions through {@code #ci.bitbucket()}. * Provides shortcuts for Bitbucket Code Insights report workflows including @@ -38,31 +32,14 @@ * with REST plumbing. */ @Reflectable -@RequiredArgsConstructor @SpelFunctionPrefix("bitbucket.") -public class ActionBitbucketSpelFunctions implements IActionSpelFunctions { +public class ActionBitbucketSpelFunctions extends ActionBitbucketCiInfoSpelFunctions { private final ActionRunnerContextLocal ctx; - private final BitbucketEnvironment env; private BitbucketRestHelper restHelper; public ActionBitbucketSpelFunctions(ActionRunnerContextLocal ctx) { + super(); this.ctx = ctx; - this.env = BitbucketEnvironment.detect(); - } - - @SpelFunction(cat=ci, desc="Returns Bitbucket Pipelines environment data as ObjectNode (auto-detected for the current step)", - returns="Environment data or `null` if not running in Bitbucket Pipelines", - returnType=BitbucketEnvironment.class) - @Override - public ObjectNode getEnv() { - return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; - } - - @SpelFunction(cat=ci, desc="Returns CI system type identifier", - returns="\"bitbucket\"") - @Override - public String getType() { - return BitbucketEnvironment.TYPE; } /** diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubCiInfoSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubCiInfoSpelFunctions.java new file mode 100644 index 00000000000..2f9cb2e3fe5 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubCiInfoSpelFunctions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci.github; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.ci; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; +import com.fortify.cli.common.ci.github.GitHubEnvironment; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; + +@Reflectable +@SpelFunctionPrefix("github.") +public class ActionGitHubCiInfoSpelFunctions implements IActionSpelFunctions { + protected final GitHubEnvironment env; + + public ActionGitHubCiInfoSpelFunctions() { + this(GitHubEnvironment.detect()); + } + + protected ActionGitHubCiInfoSpelFunctions(GitHubEnvironment env) { + this.env = env; + } + + @SpelFunction(cat=ci, desc="Returns GitHub Actions environment data as ObjectNode (auto-detected for the current workflow run)", + returns="Environment data or `null` if not running in GitHub Actions", + returnType=GitHubEnvironment.class) + @Override + public ObjectNode getEnv() { + return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; + } + + @SpelFunction(cat=ci, desc="Returns CI system type identifier", + returns="\"github\"") + @Override + public String getType() { + return GitHubEnvironment.TYPE; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java index 33f8e720fe5..9fde6dce06a 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubRepo.java @@ -68,6 +68,16 @@ public ObjectNode addPrComment( return repo.createPullRequestComment(env.pullRequest().id(), body); } + @SpelFunction(cat=ci, desc="Creates a pull request in the repository detected from the current workflow run.", + returns="Created pull request object from GitHub API") + public ObjectNode createPullRequest( + @SpelFunctionParam(name="title", desc="pull request title") String title, + @SpelFunctionParam(name="head", desc="branch containing the changes") String head, + @SpelFunctionParam(name="base", desc="branch to merge into") String base, + @SpelFunctionParam(name="body", desc="pull request description (Markdown supported)") String body) { + return repo.createPullRequest(title, head, base, body); + } + @SpelFunction(cat=ci, desc="(PREVIEW) Adds a review comment on a specific file and line in the pull request detected from the workflow run. This function is not yet used by any built-in fcli actions; signature and implementation may change in future fcli versions based on new insights as to how to best integrate this functionality into fcli built-in actions.", returns="Created review comment object") public ObjectNode addReviewComment( diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubSpelFunctions.java index e40aacc7c13..d36b467959c 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/github/ActionGitHubSpelFunctions.java @@ -14,21 +14,15 @@ import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.ci; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; -import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; import com.fortify.cli.common.action.runner.ActionRunnerContextLocal; -import com.fortify.cli.common.ci.github.GitHubEnvironment; import com.fortify.cli.common.ci.github.GitHubRestHelper; import com.fortify.cli.common.ci.github.GitHubUnirestInstanceSupplier; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.spel.fn.descriptor.annotation.RenderSubFunctionsMode; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; -import lombok.RequiredArgsConstructor; - /** * Action-friendly GitHub helper providing convenient methods for CI/CD workflows. * Automatically detects GitHub Actions environment and provides both high-level @@ -39,11 +33,9 @@ * @author rsenden */ @Reflectable -@RequiredArgsConstructor @SpelFunctionPrefix("github.") -public class ActionGitHubSpelFunctions implements IActionSpelFunctions { +public class ActionGitHubSpelFunctions extends ActionGitHubCiInfoSpelFunctions { private final ActionRunnerContextLocal ctx; - private final GitHubEnvironment env; private GitHubRestHelper restHelper; /** @@ -51,31 +43,8 @@ public class ActionGitHubSpelFunctions implements IActionSpelFunctions { * Does not throw if not in CI - use getEnv() != null to check. */ public ActionGitHubSpelFunctions(ActionRunnerContextLocal ctx) { + super(); this.ctx = ctx; - this.env = GitHubEnvironment.detect(); - } - - /** - * Get environment data as ObjectNode for use in actions. - * Returns null if not running in GitHub Actions. - * Can be accessed in action YAML as: ${#ci.github().env} - */ - @SpelFunction(cat=ci, desc="Returns GitHub Actions environment data as ObjectNode (auto-detected for the current workflow run)", - returns="Environment data or `null` if not running in GitHub Actions", - returnType=GitHubEnvironment.class) - @Override - public ObjectNode getEnv() { - return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; - } - - /** - * Returns "github" as the CI system type. - */ - @SpelFunction(cat=ci, desc="Returns CI system type identifier", - returns="\"github\"") - @Override - public String getType() { - return GitHubEnvironment.TYPE; } /** diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabCiInfoSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabCiInfoSpelFunctions.java new file mode 100644 index 00000000000..f508217e9d2 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabCiInfoSpelFunctions.java @@ -0,0 +1,52 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci.gitlab; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.ci; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; +import com.fortify.cli.common.ci.gitlab.GitLabEnvironment; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; + +@Reflectable +@SpelFunctionPrefix("gitlab.") +public class ActionGitLabCiInfoSpelFunctions implements IActionSpelFunctions { + protected final GitLabEnvironment env; + + public ActionGitLabCiInfoSpelFunctions() { + this(GitLabEnvironment.detect()); + } + + protected ActionGitLabCiInfoSpelFunctions(GitLabEnvironment env) { + this.env = env; + } + + @SpelFunction(cat=ci, desc="Returns GitLab CI environment data as ObjectNode (auto-detected for the current pipeline run)", + returns="Environment data or `null` if not running in GitLab CI", + returnType=GitLabEnvironment.class) + @Override + public ObjectNode getEnv() { + return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; + } + + @SpelFunction(cat=ci, desc="Returns CI system type identifier", + returns="\"gitlab\"") + @Override + public String getType() { + return GitLabEnvironment.TYPE; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java index 6a39e04140b..2970765ea4b 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabProject.java @@ -69,6 +69,16 @@ public ObjectNode addMrComment( return project.createMergeRequestNote(env.pullRequest().id(), body); } + @SpelFunction(cat=ci, desc="Creates a merge request in the project detected from the current pipeline run.", + returns="Created merge request object from GitLab API") + public ObjectNode createMergeRequest( + @SpelFunctionParam(name="title", desc="merge request title") String title, + @SpelFunctionParam(name="sourceBranch", desc="branch containing the changes") String sourceBranch, + @SpelFunctionParam(name="targetBranch", desc="branch to merge into") String targetBranch, + @SpelFunctionParam(name="description", desc="merge request description (Markdown supported)") String description) { + return project.createMergeRequest(title, sourceBranch, targetBranch, description); + } + private String requirePipelineId(String operation) { var pipelineId = env.pipelineId(); if (StringUtils.isBlank(pipelineId)) { diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabSpelFunctions.java index 235c02a450b..ac6c55beb09 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/ci/gitlab/ActionGitLabSpelFunctions.java @@ -16,21 +16,15 @@ import org.apache.commons.lang3.StringUtils; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; -import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; import com.fortify.cli.common.action.runner.ActionRunnerContextLocal; -import com.fortify.cli.common.ci.gitlab.GitLabEnvironment; import com.fortify.cli.common.ci.gitlab.GitLabRestHelper; import com.fortify.cli.common.ci.gitlab.GitLabUnirestInstanceSupplier; import com.fortify.cli.common.exception.FcliSimpleException; -import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.spel.fn.descriptor.annotation.RenderSubFunctionsMode; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; -import lombok.RequiredArgsConstructor; - /** * Action-friendly GitLab helper providing convenient methods for CI/CD workflows. * Automatically detects GitLab CI environment and provides both high-level @@ -41,11 +35,9 @@ * @author rsenden */ @Reflectable -@RequiredArgsConstructor @SpelFunctionPrefix("gitlab.") -public class ActionGitLabSpelFunctions implements IActionSpelFunctions { +public class ActionGitLabSpelFunctions extends ActionGitLabCiInfoSpelFunctions { private final ActionRunnerContextLocal ctx; - private final GitLabEnvironment env; private GitLabRestHelper restHelper; /** @@ -53,31 +45,8 @@ public class ActionGitLabSpelFunctions implements IActionSpelFunctions { * Does not throw if not in CI - use getEnv() != null to check. */ public ActionGitLabSpelFunctions(ActionRunnerContextLocal ctx) { + super(); this.ctx = ctx; - this.env = GitLabEnvironment.detect(); - } - - /** - * Get environment data as ObjectNode for use in actions. - * Returns null if not running in GitLab CI. - * Can be accessed in action YAML as: ${#ci.gitlab().env} - */ - @SpelFunction(cat=ci, desc="Returns GitLab CI environment data as ObjectNode (auto-detected for the current pipeline run)", - returns="Environment data or `null` if not running in GitLab CI", - returnType=GitLabEnvironment.class) - @Override - public ObjectNode getEnv() { - return env != null ? JsonHelper.getObjectMapper().valueToTree(env) : null; - } - - /** - * Returns "gitlab" as the CI system type. - */ - @SpelFunction(cat=ci, desc="Returns CI system type identifier", - returns="\"gitlab\"") - @Override - public String getType() { - return GitLabEnvironment.TYPE; } /** diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java new file mode 100644 index 00000000000..487a993d522 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java @@ -0,0 +1,513 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.git; + +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.util; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.Set; +import java.util.TreeSet; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.RefNotFoundException; +import org.eclipse.jgit.api.errors.TransportException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.PushResult; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.ci.CiBranch; +import com.fortify.cli.common.ci.CiCommit; +import com.fortify.cli.common.ci.CiCommitId; +import com.fortify.cli.common.ci.CiCommitMessage; +import com.fortify.cli.common.ci.CiGitCredentials; +import com.fortify.cli.common.ci.CiGitCredentialsHelper; +import com.fortify.cli.common.ci.CiPerson; +import com.fortify.cli.common.ci.CiRepository; +import com.fortify.cli.common.ci.CiRepositoryName; +import com.fortify.cli.common.ci.LocalRepoInfo; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionParam; +import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionPrefix; +import com.fortify.cli.common.util.EnvHelper; + +import lombok.extern.slf4j.Slf4j; + +/** + * SpEL functions for performing Git operations on a local repository. + * Provides functionality for checking working tree status, creating branches, + * staging files, committing, and pushing changes to a remote. + * + * Available via the {@code #git} SpEL variable in action YAML files. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +@SpelFunctionPrefix("git.") +@Slf4j +public class ActionGitSpelFunctions { + public static final ActionGitSpelFunctions INSTANCE = new ActionGitSpelFunctions(); + + @SpelFunction(cat = util, desc = """ + Returns basic information about the local git repository for the given source directory, or null if the + directory is not inside a git working tree. Only constant-time lookups are performed (HEAD commit only). + Structure: + { + repository: { workspaceDir, remoteUrl?, name: { short, full? } }, + branch: { full?, short? }, + commit: { + headId: { full, short }, + mergeId: { full, short }, + message: { short, full }, + author: { name, email, when }, + committer: { name, email, when } + } + } + """, returns = "Git repository information or null if not a git work dir", returnType = LocalRepoInfo.class) + public ObjectNode localRepo( + @SpelFunctionParam(name = "sourceDir", desc = "directory assumed to be inside a git working tree") String sourceDir) { + if (StringUtils.isBlank(sourceDir)) { + return null; + } + var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); + if (!dir.exists()) { + return null; + } + FileRepositoryBuilder builder = new FileRepositoryBuilder().findGitDir(dir); + if (builder.getGitDir() == null) { + return null; + } + try (Repository repo = builder.build()) { + var mapper = JsonHelper.getObjectMapper(); + var remote = selectRemote(repo); + var remoteUrl = remote == null ? null : repo.getConfig().getString("remote", remote, "url"); + var names = deriveRepoNames(dir.getName(), remoteUrl); + var repository = CiRepository.builder() + .workspaceDir(repo.getWorkTree().getAbsolutePath()) + .remoteUrl(StringUtils.isBlank(remoteUrl) ? null : remoteUrl) + .name(CiRepositoryName.builder() + .short_(names[0]) + .full(names[1]) + .build()) + .build(); + + CiBranch branch = null; + try { + String fullBranch = repo.getFullBranch(); + if (fullBranch != null) { + branch = CiBranch.builder() + .full(fullBranch) + .short_(Repository.shortenRefName(fullBranch)) + .build(); + } + } catch (Exception e) { + /* ignore */ } + + CiCommit commit = null; + var headId = repo.resolve("HEAD"); + if (headId != null) { + try (var walk = new RevWalk(repo)) { + var gitCommit = walk.parseCommit(headId); + String shortId; + try { + var abbrev = repo.newObjectReader().abbreviate(gitCommit.getId(), 8); + shortId = abbrev.name(); + } catch (Exception ex) { + shortId = gitCommit.getId().getName().substring(0, 8); + } + + var authorIdent = gitCommit.getAuthorIdent(); + var committerIdent = gitCommit.getCommitterIdent(); + + var commitId = CiCommitId.builder() + .full(gitCommit.getId().getName()) + .short_(shortId) + .build(); + + commit = CiCommit.builder() + .headId(commitId) + .mergeId(commitId) + .message(CiCommitMessage.builder() + .short_(gitCommit.getShortMessage()) + .full(gitCommit.getFullMessage()) + .build()) + .author(authorIdent != null ? CiPerson.builder() + .name(authorIdent.getName()) + .email(authorIdent.getEmailAddress()) + .when(authorIdent.getWhenAsInstant().toString()) + .build() : null) + .committer(committerIdent != null ? CiPerson.builder() + .name(committerIdent.getName()) + .email(committerIdent.getEmailAddress()) + .when(committerIdent.getWhenAsInstant().toString()) + .build() : null) + .build(); + } catch (Exception e) { + /* ignore */ } + } + + var root = mapper.createObjectNode(); + root.set("repository", mapper.valueToTree(repository)); + if (branch != null) { + root.set("branch", mapper.valueToTree(branch)); + } + if (commit != null) { + root.set("commit", mapper.valueToTree(commit)); + } + return root; + } catch (Exception e) { + return null; + } + } + + @SpelFunction(cat = util, desc = """ + Captures a snapshot of the paths that currently have uncommitted changes in the working tree + (modified, added, removed, missing, changed, conflicting, or untracked). This snapshot can be + passed to #git.commitChangesSince to commit only the changes introduced afterwards, leaving + pre-existing changes (e.g. build output) untouched. Structure: { paths: [ "relative/path", ... ] } + """, returns = "Snapshot of currently dirty paths, or null if the directory is not a git working tree") + public ObjectNode status( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + try (var git = openGit(sourceDir)) { + if (git == null) { + return null; + } + var root = JsonHelper.getObjectMapper().createObjectNode(); + var paths = root.putArray("paths"); + collectDirtyPaths(git.status().call()).forEach(paths::add); + return root; + } catch (GitAPIException e) { + throw new FcliSimpleException("Failed to determine git status: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = """ + Creates and checks out a new branch, stages only the changes introduced since the given snapshot (as + returned by #git.status), and commits them with the given author and message. Paths that were already + dirty in the snapshot (e.g. build artifacts) are left uncommitted. Returns null without creating a + branch or commit if there are no new changes to commit. + """, returns = "The commit SHA, or null if there were no new changes to commit") + public String commitChangesSince( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @SpelFunctionParam(name = "snapshot", desc = "dirty-paths snapshot from #git.status") JsonNode snapshot, + @SpelFunctionParam(name = "branchName", desc = "full branch name to create and checkout") String branchName, + @SpelFunctionParam(name = "message", desc = "commit message") String message, + @SpelFunctionParam(name = "name", desc = "commit author name") String name, + @SpelFunctionParam(name = "email", desc = "commit author email") String email) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + var newPaths = collectDirtyPaths(git.status().call()); + newPaths.removeAll(snapshotPaths(snapshot)); + if (newPaths.isEmpty()) { + return null; + } + createAndCheckoutBranch(git, branchName); + stagePaths(git, newPaths); + var commit = git.commit() + .setMessage(message) + .setAuthor(name, email) + .setCommitter(name, email) + .call(); + return commit.getId().getName(); + } catch (GitAPIException | IOException e) { + throw new FcliSimpleException("Failed to commit changes: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = """ + Pushes the given branch to the remote repository. For HTTPS remotes, authentication uses the + GIT_PUSH_TOKEN environment variable if set, falling back to the token of the active CI system; if + neither is available or authentication fails, the push is retried using the local git configuration + (e.g. an http.extraHeader injected by the CI checkout step). For SSH remotes, the configured SSH keys + are used. Returns the pushed ref. + """, returns = "The name of the remote ref that was pushed") + public String push( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @SpelFunctionParam(name = "branchName", desc = "name of the branch to push") String branchName) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + var repo = git.getRepository(); + var remote = StringUtils.defaultString(selectRemote(repo), "origin"); + ensureOnBranch(git, branchName); + var remoteUrl = repo.getConfig().getString("remote", remote, "url"); + var refSpec = new RefSpec("refs/heads/" + branchName + ":refs/heads/" + branchName); + var credentialsProvider = resolveCredentialsProvider(remoteUrl); + try { + push(git, remote, refSpec, credentialsProvider); + } catch (Exception e) { + if (credentialsProvider == null || !isLikelyAuthFailure(e)) { + throw e; + } + log.debug("Push using resolved credentials failed ({}); retrying with local git configuration", + rootMessage(e)); + push(git, remote, refSpec, null); + } + return "refs/heads/" + branchName; + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to push branch " + branchName + ": " + rootMessage(e), e); + } + } + + private void push(Git git, String remote, RefSpec refSpec, CredentialsProvider credentialsProvider) + throws GitAPIException { + var pushCmd = git.push().setRemote(remote).setRefSpecs(refSpec).setTimeout(300); + if (credentialsProvider != null) { + pushCmd.setCredentialsProvider(credentialsProvider); + } + verifyPushResults(pushCmd.call(), remote); + } + + @SpelFunction(cat = util, desc = "Detects the default branch of the remote repository. Checks CI environment variables (CI_DEFAULT_BRANCH), then falls back to reading refs/remotes//HEAD from the local git config. Returns null if detection fails.", returns = "The default branch name (e.g. 'main', 'master', 'develop') or null if not detectable") + public String defaultBranch( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + var defaultBranch = EnvHelper.env("CI_DEFAULT_BRANCH"); + if (StringUtils.isNotBlank(defaultBranch)) { + return defaultBranch; + } + try (var git = openGit(sourceDir)) { + if (git == null) { + return null; + } + var repo = git.getRepository(); + var remoteDefaultBranch = detectDefaultBranchFromRemoteHeads(repo); + if (StringUtils.isNotBlank(remoteDefaultBranch)) { + return remoteDefaultBranch; + } + } catch (Exception e) { + log.debug("Error detecting default branch", e); + } + return null; + } + + private Git openGit(String sourceDir) { + if (StringUtils.isBlank(sourceDir)) { + return null; + } + try { + var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); + if (!dir.exists()) { + return null; + } + var builder = new FileRepositoryBuilder().findGitDir(dir); + if (builder.getGitDir() == null) { + return null; + } + return new Git(builder.build()); + } catch (Exception e) { + return null; + } + } + + private void ensureOnBranch(Git git, String branchName) throws GitAPIException, IOException { + if (branchName.equals(git.getRepository().getBranch())) { + return; + } + try { + git.checkout().setName(branchName).call(); + } catch (RefNotFoundException e) { + git.checkout().setCreateBranch(true).setName(branchName).setStartPoint("HEAD").call(); + } + } + + private void createAndCheckoutBranch(Git git, String branchName) throws GitAPIException, IOException { + git.checkout().setCreateBranch(true).setName(branchName).call(); + if (!branchName.equals(git.getRepository().getBranch())) { + throw new FcliSimpleException("Failed to checkout branch " + branchName); + } + } + + private void stagePaths(Git git, Set paths) throws GitAPIException { + var workTree = git.getRepository().getWorkTree(); + var addNew = git.add(); + var addDeleted = git.add().setUpdate(true); + var hasNew = false; + var hasDeleted = false; + for (var path : paths) { + if (new File(workTree, path).exists()) { + addNew.addFilepattern(path); + hasNew = true; + } else { + addDeleted.addFilepattern(path); + hasDeleted = true; + } + } + if (hasNew) { + addNew.call(); + } + if (hasDeleted) { + addDeleted.call(); + } + } + + private static Set collectDirtyPaths(Status status) { + var paths = new TreeSet(); + paths.addAll(status.getModified()); + paths.addAll(status.getChanged()); + paths.addAll(status.getAdded()); + paths.addAll(status.getRemoved()); + paths.addAll(status.getMissing()); + paths.addAll(status.getUntracked()); + paths.addAll(status.getConflicting()); + return paths; + } + + private static Set snapshotPaths(JsonNode snapshot) { + var paths = new TreeSet(); + if (snapshot != null && snapshot.get("paths") instanceof ArrayNode arr) { + arr.forEach(node -> paths.add(node.asText())); + } + return paths; + } + + private static CredentialsProvider resolveCredentialsProvider(String remoteUrl) { + CiGitCredentials credentials = CiGitCredentialsHelper.resolvePushCredentials(remoteUrl); + if (credentials == null) { + return null; + } + return new UsernamePasswordCredentialsProvider(credentials.username(), credentials.token()); + } + + private static void verifyPushResults(Iterable results, String remote) { + var updated = false; + for (var result : results) { + for (var update : result.getRemoteUpdates()) { + switch (update.getStatus()) { + case OK: + case UP_TO_DATE: + updated = true; + break; + default: + throw new FcliSimpleException("Push to " + remote + " rejected: status=" + update.getStatus() + + (update.getMessage() != null ? " (" + update.getMessage() + ")" : "")); + } + } + } + if (!updated) { + throw new FcliSimpleException("Push to " + remote + + " did not update any refs (likely an authentication or permission issue)"); + } + } + + private static boolean isLikelyAuthFailure(Throwable e) { + for (var cause = e; cause != null; cause = cause.getCause()) { + if (cause instanceof TransportException) { + return true; + } + var message = cause.getMessage(); + if (message != null) { + var lower = message.toLowerCase(); + if (lower.contains("401") || lower.contains("403") || lower.contains("auth") + || lower.contains("not authorized") || lower.contains("forbidden") + || lower.contains("permission")) { + return true; + } + } + } + return false; + } + + private static String detectDefaultBranchFromRemoteHeads(Repository repo) { + var remoteNames = repo.getRemoteNames(); + if (remoteNames == null || remoteNames.isEmpty()) { + return null; + } + try { + for (var remote : remoteNames) { + var refName = "refs/remotes/" + remote + "/HEAD"; + var ref = repo.exactRef(refName); + if (ref != null && ref.getTarget() != null) { + var target = ref.getTarget().getName(); + var prefix = "refs/remotes/" + remote + "/"; + if (target.startsWith(prefix)) { + return target.substring(prefix.length()); + } + } + } + } catch (IOException e) { + log.debug("Failed to resolve default branch from remote HEAD", e); + } + return null; + } + + private static String rootMessage(Throwable e) { + var root = e; + while (root.getCause() != null) { + root = root.getCause(); + } + return root.getMessage(); + } + + private static String selectRemote(Repository repo) { + try { + var remotes = repo.getRemoteNames(); + if (remotes == null || remotes.isEmpty()) + return null; + return remotes.contains("origin") ? "origin" : remotes.iterator().next(); + } catch (Exception e) { + return null; + } + } + + private static String[] deriveRepoNames(String fallbackShort, String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return new String[] { fallbackShort, null }; + } + try { + var cleaned = remoteUrl.trim(); + if (cleaned.endsWith(".git")) { + cleaned = cleaned.substring(0, cleaned.length() - 4); + } + String pathPart; + if (cleaned.startsWith("git@")) { + int idx = cleaned.indexOf(":"); + pathPart = idx >= 0 ? cleaned.substring(idx + 1) : cleaned; + } else { + var uri = URI.create(cleaned); + pathPart = uri.getPath(); + if (pathPart.startsWith("/")) { + pathPart = pathPart.substring(1); + } + } + var parts = pathPart.split("/"); + if (parts.length >= 2) { + var shortName = parts[parts.length - 1]; + return new String[] { shortName, pathPart }; + } + return new String[] { parts[parts.length - 1], null }; + } catch (Exception e) { + return new String[] { fallbackShort, null }; + } + } + +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerConfig.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerConfig.java index 8d251feb8a4..2da6ef30b06 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerConfig.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerConfig.java @@ -20,6 +20,7 @@ import org.springframework.expression.spel.support.SimpleEvaluationContext; +import com.fortify.cli.common.action.helper.ci.ActionCiSpelFunctionsRegistry; import com.fortify.cli.common.action.model.Action; import com.fortify.cli.common.cli.util.SimpleOptionsParser.OptionsParseResult; import com.fortify.cli.common.progress.helper.IProgressWriterI18n; @@ -75,6 +76,7 @@ private static final class ActionConfigSpelEvaluatorFactory extends AbstractSpel private final ActionRunnerConfig config; protected final void configureSpelContext(SimpleEvaluationContext spelContext) { + ActionCiSpelFunctionsRegistry.registerInfoVariables(spelContext); configureSpelContext(spelContext, config.getActionConfigSpelEvaluatorConfigurers(), config); } } diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java index f8ab40712f0..4965b087c33 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextLocal.java @@ -22,13 +22,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.action.helper.ci.ActionCiSpelFunctions; -import com.fortify.cli.common.action.helper.ci.IActionSpelFunctions; -import com.fortify.cli.common.action.helper.ci.ado.ActionAdoSpelFunctions; -import com.fortify.cli.common.action.helper.ci.bitbucket.ActionBitbucketSpelFunctions; -import com.fortify.cli.common.action.helper.ci.github.ActionGitHubSpelFunctions; -import com.fortify.cli.common.action.helper.ci.gitlab.ActionGitLabSpelFunctions; +import com.fortify.cli.common.action.helper.ci.ActionCiSpelFunctionsRegistry; import com.fortify.cli.common.action.helper.fs.ActionFileSystemSpelFunctions; +import com.fortify.cli.common.action.helper.git.ActionGitSpelFunctions; import com.fortify.cli.common.action.model.ActionStepCheckEntry; import com.fortify.cli.common.action.model.ActionStepCheckEntry.CheckStatus; import com.fortify.cli.common.action.model.FcliActionValidationException; @@ -223,7 +219,7 @@ void reconfigureWithContext(ActionRunnerContextLocal ctx) { getSpelEvaluator().configure(spelCtx -> { configureSpelContext(spelCtx, global.getConfig().getActionContextSpelEvaluatorConfigurers(), ctx); spelCtx.setVariable("action", new ActionRunnerContextSpelFunctions(ctx)); - registerCiVariables(spelCtx, ctx); + ActionCiSpelFunctionsRegistry.registerRuntimeVariables(spelCtx, ctx); }); } @@ -234,24 +230,14 @@ protected final void configureSpelContext(SimpleEvaluationContext spelContext) { if (actionRunnerContext != null) { configureSpelContext(spelContext, config.getActionContextSpelEvaluatorConfigurers(), actionRunnerContext); spelContext.setVariable("action", new ActionRunnerContextSpelFunctions(actionRunnerContext)); - registerCiVariables(spelContext, actionRunnerContext); + ActionCiSpelFunctionsRegistry.registerRuntimeVariables(spelContext, actionRunnerContext); + } else { + ActionCiSpelFunctionsRegistry.registerInfoVariables(spelContext); } spelContext.setVariable("fs", ActionFileSystemSpelFunctions.INSTANCE); + spelContext.setVariable("git", ActionGitSpelFunctions.INSTANCE); spelContext.setVariable("fcli", FcliCommandsSpelFunctions.INSTANCE); } - - private void registerCiVariables(SimpleEvaluationContext spelContext, ActionRunnerContextLocal ctx) { - var ciSpecificSpelFunctions = new IActionSpelFunctions[] { - new ActionGitHubSpelFunctions(ctx), - new ActionGitLabSpelFunctions(ctx), - new ActionAdoSpelFunctions(ctx), - new ActionBitbucketSpelFunctions(ctx) - }; - spelContext.setVariable("_ci", new ActionCiSpelFunctions(ciSpecificSpelFunctions)); - for ( var ciSpelFunctions : ciSpecificSpelFunctions ) { - spelContext.setVariable(ciSpelFunctions.getType(), ciSpelFunctions); - } - } } @Override diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextSpelFunctions.java index 61fb7d1556c..44ff3fce1bb 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionRunnerContextSpelFunctions.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.ActionLoaderHelper; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction; import com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunctionParam; @@ -289,6 +290,10 @@ private String processFcliCmdReferences(String text) { return result.toString(); } + private String productToModuleName(String product) { + return "generic".equals(product) ? "generic_action" : product; + } + private String processActionReferences(String text) { // Pattern: actionRef:product:action[#anchor] // product: generic, fod, ssc, or _ (current/generic) @@ -317,7 +322,7 @@ private String processActionReferences(String text) { } String replacement; - if (isAsciiDoc) { + if (isAsciiDoc && ActionLoaderHelper.hasBuiltInAction(productToModuleName(resolvedProduct), actionName)) { // Use FcliBuildProperties.getFcliDocBaseUrl() to construct absolute URL String baseUrl = FcliBuildProperties.INSTANCE.getFcliDocBaseUrl(); String url = baseUrl + "/" + resolvedProduct + "-actions.html" + anchor; diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java index 541e321af35..a870babf9ef 100644 --- a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/runner/ActionSpelFunctions.java @@ -15,12 +15,12 @@ import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.date; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.fcli; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.fortify; +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.http; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.internal; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.txt; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.util; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.workflow; -import java.net.URI; import java.nio.file.Path; import java.time.LocalDateTime; import java.time.Year; @@ -30,17 +30,10 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevWalk; -import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -53,16 +46,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.formkiq.graalvm.annotations.Reflectable; import com.fortify.cli.common.action.helper.ActionLoaderHelper; -import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionSource; -import com.fortify.cli.common.action.helper.ActionLoaderHelper.ActionValidationHandler; +import com.fortify.cli.common.action.helper.git.ActionGitSpelFunctions; import com.fortify.cli.common.action.schema.ActionSchemaDescriptorFactory; -import com.fortify.cli.common.ci.CiBranch; -import com.fortify.cli.common.ci.CiCommit; -import com.fortify.cli.common.ci.CiCommitId; -import com.fortify.cli.common.ci.CiCommitMessage; -import com.fortify.cli.common.ci.CiPerson; -import com.fortify.cli.common.ci.CiRepository; -import com.fortify.cli.common.ci.CiRepositoryName; import com.fortify.cli.common.ci.LocalRepoInfo; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.FortifyTraceNodeHelper; @@ -85,8 +70,6 @@ public class ActionSpelFunctions { private static final String CODE_END = "\n===== CODE END =====\n"; private static final Pattern CODE_PATTERN = Pattern.compile(String.format("%s(.*?)%s", CODE_START, CODE_END), Pattern.DOTALL); private static final Pattern uriPartsPattern = Pattern.compile("^(?(?:(?[A-Za-z]+):)?(\\/{0,3})(?[0-9.\\-A-Za-z]+)(?::(?\\d+))?)(?\\/(?[^?#]*))?(?:\\?(?[^#]*))?(?:#(?.*))?$"); - private static final Map> builtinActionNamesByModule = new ConcurrentHashMap<>(); - @SpelFunction(cat=util, desc="Resolves the given path against the current working directory.", returns="The absolute, normalized path") public static final String resolveAgainstCurrentWorkDir( @@ -94,7 +77,7 @@ public static final String resolveAgainstCurrentWorkDir( { return Path.of(".").resolve(path).toAbsolutePath().normalize().toString(); } - + @SpelFunction(cat=workflow, desc = "Throws an error with the given message if the first argument evaluates to true.", returns="`true` if no error is thrown") public static final boolean check( @@ -107,9 +90,9 @@ public static final boolean check( return true; } } - + @SpelFunction(cat=txt, desc = "Repeats the input text a specified number of times.", - returns= "The input text repeated the given number of times") + returns= "The input text repeated the given number of times") public static final String repeat( @SpelFunctionParam(name="input", desc="the text to repeat.") String text, @SpelFunctionParam(name="count", desc="the number of times to repeat the text; if <=0, an empty string will be returned") int count) @@ -119,7 +102,7 @@ public static final String repeat( for (int i = 0; i < count; i++) { sb.append(text);} return sb.toString(); } - + @SpelFunction(cat=txt, desc = "Converts the given HTML string into plain text.", returns="The plain text extracted from the input HTML, or `null` if the input is `null`") public static final String htmlToText( @@ -129,16 +112,16 @@ public static final String htmlToText( Document document = ActionSpelFunctionsJsoupHelper.asDocument(html); return ActionSpelFunctionsJsoupHelper.documentToPlainText(document); } - + @SpelFunction(cat=txt, desc = "Converts the given HTML string into a single-line plain text string by removing all HTML tags.", returns="The plain text representation of the given HTML input, or `null` if the input is `null`") public static final String htmlToSingleLineText( - @SpelFunctionParam(name="html", desc="the HTML string to convert to single-line plain text") String html) + @SpelFunctionParam(name="html", desc="the HTML string to convert to single-line plain text") String html) { if (html == null) { return null; } return Jsoup.clean(html, "", Safelist.none()); } - + @SpelFunction(cat=fortify, desc = "Cleans the given rule description and returns it as plain text.", returns="The cleaned rule description, or empty string if input is `null`") public static final String cleanRuleDescription( @@ -149,7 +132,7 @@ public static final String cleanRuleDescription( var paragraphs = document.select("Paragraph"); for (var p : paragraphs) { var altParagraph = p.select("AltParagraph"); - if (!altParagraph.isEmpty()) { p.replaceWith(new TextNode(String.join("\n\n", altParagraph.eachText())));} + if (!altParagraph.isEmpty()) { p.replaceWith(new TextNode(String.join("\n\n", altParagraph.eachText())));} else { p.remove(); } } document.select("IfDef").remove(); @@ -168,17 +151,17 @@ public static final String cleanIssueDescription( return ActionSpelFunctionsJsoupHelper.documentToPlainText(document); } - @SpelFunction(cat=util, desc = "Retrieves a given part of the given URI", - returns="Requested part of the given URI, or `null` if part name is not valid or not present") + @SpelFunction(cat=http, desc = "Retrieves a given part of the given URI", + returns="Requested part of the given URI, or `null` if part name is not valid or not present") public static final String uriPart( - @SpelFunctionParam(name="uri", desc="URI from which to retrieve the requested part") String uriString, + @SpelFunctionParam(name="uri", desc="URI from which to retrieve the requested part") String uriString, @SpelFunctionParam(name="part", desc="URI part to be returned; may be one of serverUrl, protocol, host, port, path, relativePath, query, fragment") String part) { if ( StringUtils.isBlank(uriString) ) {return null;} Matcher matcher = uriPartsPattern.matcher(uriString); return matcher.matches() ? matcher.group(part) : null; } - + @SpelFunction(cat=date, desc = """ Returns either current or given date/time formatted according to the given formatter pattern. See 'Patterns for Formatting and Parsing' section at @@ -196,7 +179,7 @@ public static final String formatDateTime( var dateString = dateStrings == null || dateStrings.length == 0 ? currentDateTime() : dateStrings[0]; return formatDateTimeWithZoneId(pattern, dateString, ZoneId.systemDefault()); } - + @SpelFunction(cat=date, desc = """ Returns given date/time formatted according to the given formatter pattern. See 'Patterns for Formatting and Parsing' section at @@ -213,9 +196,9 @@ public static final String formatDateTimeWithZoneId( ZonedDateTime zonedDateTime = new JSONDateTimeConverter(defaultZoneId).parseZonedDateTime(dateString); return DateTimeFormatter.ofPattern(pattern).format(zonedDateTime); } - + @SpelFunction(cat=date, desc = """ - Converts given date/time to UTC time zone and formats the result according to the given formatter pattern. + Converts given date/time to UTC time zone and formats the result according to the given formatter pattern. See 'Patterns for Formatting and Parsing' section at https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/format/DateTimeFormatter.html for details on formatter pattern syntax. If the given date/time doesn't include time zone, system @@ -224,13 +207,13 @@ public static final String formatDateTimeWithZoneId( returns="Formatted date/time") public static final String formatDateTimeAsUTC( @SpelFunctionParam(name="fmt", desc="formatter pattern used to format given date/time") String pattern, - @SpelFunctionParam(name="input", desc="date/time in JSON format to be formatted") String dateString) + @SpelFunctionParam(name="input", desc="date/time in JSON format to be formatted") String dateString) { return formatDateTimewithZoneIdAsUTC(pattern, dateString, ZoneId.systemDefault()); } - + @SpelFunction(cat=date, desc = """ - Converts given date/time to UTC time zone and formats the result according to the given formatter pattern. + Converts given date/time to UTC time zone and formats the result according to the given formatter pattern. See 'Patterns for Formatting and Parsing' section at https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/format/DateTimeFormatter.html for details on formatter pattern syntax. If the given date/time doesn't include time zone, the given @@ -246,7 +229,7 @@ public static final String formatDateTimewithZoneIdAsUTC( LocalDateTime utcDateTime = LocalDateTime.ofInstant(zonedDateTime.toInstant(), ZoneOffset.UTC); return DateTimeFormatter.ofPattern(pattern).format(utcDateTime); } - + @SpelFunction(cat=date, returns="The current date/time as `yyyy-MM-dd HH:mm:ss`") public static final String currentDateTime() { return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now()); @@ -255,27 +238,27 @@ public static final String currentDateTime() { @SpelFunction(cat=workflow, desc= """ Constructs an fcli command for running an fcli action based on function arguments combined with user-supplied environment variables. Example environment variable names: - + * If `envPrefix` is `SETUP`, we look for `SETUP_ACTION` and `SETUP_EXTRA_OPTS` * If `envPrefix` is `PACKAGE_ACTION`, we look for `PACKAGE_ACTION` and `PACKAGE_ACTION_EXTRA_OPTS` - + As can be seen in the second example, if the given envPrefix already ends with `_ACTION`, \ the extra `_ACTION` suffix is skipped to avoid variable names like `PACKAGE_ACTION_ACTION`. \ Note though that we do keep `_ACTION` in `*_ACTION_EXTRA_OPTS`, to allow for having both \ PACKAGE_EXTRA_OPTS (on the `scancentral package` command), and PACKAGE_ACTION_EXTRA_OPTS \ (on the `fcli * action run package` command). - - This function returns an fcli command like `fcli action run `, + + This function returns an fcli command like `fcli action run `, where: - + * `` is taken from the corresponding function argument * `` is taken from either environment variable (if defined) or function argument, \ allowing the user to run a custom fcli action instead of built-in action * `` is taken from environment variable - """, + """, returns="`fcli action run `") public static final String actionCmd( - @SpelFunctionParam(name="envPrefix", desc="environment variable prefix") String envPrefix, + @SpelFunctionParam(name="envPrefix", desc="environment variable prefix") String envPrefix, @SpelFunctionParam(name="moduleName", desc="fcli module name") String moduleName, @SpelFunctionParam(name="actionName", desc="fcli action name") String actionName) { @@ -284,26 +267,26 @@ public static final String actionCmd( ActionSpelFunctionsHelper.envOrDefault(envPrefix.replaceAll("_ACTION$", ""), "ACTION", actionName), extraOpts(envPrefix)); } - + @SpelFunction(cat=workflow, desc = """ Returns the given fcli command, amended with extra options specified in an optional, user-supplied environment variable named `_EXTRA_OPTS`. """, - returns="` `") + returns="` `") public static final String fcliCmd( @SpelFunctionParam(name="envPrefix", desc="the environment variable prefix used to determine extra options") String envPrefix, @SpelFunctionParam(name="cmd", desc="the base command to be executed") String cmd) { return String.format("%s %s", cmd, extraOpts(envPrefix)); } - + @SpelFunction(cat=workflow, desc=""" Returns a skip reason if there's no action available to be run. If user configured a custom action \ for the given `envPrefix` (also see `#actionCmd(...)`, we assume that the action exists and thus \ return `null`. Otherwise, we check whether the given (built-in) action name is not blank and exists; - if not, we return an appropriate skip reason. - """, - returns="Skip reason or `null` if no reason to skip") + if not, we return an appropriate skip reason. + """, + returns="Skip reason or `null` if no reason to skip") public static final String actionCmdSkipNoActionReason( @SpelFunctionParam(name="envPrefix", desc="the environment variable prefix used to check the action environment variable") String envPrefix, @SpelFunctionParam(name="moduleName", desc="the name of the module to check for built-in actions") String moduleName, @@ -320,13 +303,13 @@ public static final String actionCmdSkipNoActionReason( } return null; } - + @SpelFunction(cat=workflow, desc=""" For use with `run.fcli::skip.if-reason`, returns a skip reason if either user explicitly \ set `DO_` to `false`, or if `skipByDefault` is `true` and user didn't explicitly \ set `DO_` to `true`. Note that `DO_: true` is implied if either \ `_ACTION` or `_EXTRA_OPTS` have been set. - """, + """, returns="Skip reason or `null` if no reason to skip") public static final String actionCmdSkipFromEnvReason( @SpelFunctionParam(name="envPrefix", desc="the environment variable prefix used to construct related environment variable names") String envPrefix, @@ -352,14 +335,14 @@ public static final String actionCmdSkipFromEnvReason( } return skipByDefault ? String.format("Set %s to 'true' to enable this step", doEnvName) : null; } - + @SpelFunction(cat=workflow, desc=""" For use with `run.fcli::skip.if-reason`, returns a skip reason if either user explicitly \ set `DO_` to `false`, or if `skipByDefault` is `true` and user didn't explicitly \ set `DO_` to `true`. Note that `DO_==true` is implied if \ `_EXTRA_OPTS` has been set. - """, - returns="Skip reason or `null` if no reason to skip") + """, + returns="Skip reason or `null` if no reason to skip") public static final String fcliCmdSkipFromEnvReason( @SpelFunctionParam(name="envPrefix", desc="the environment variable prefix used to construct related environment variable names") String envPrefix, @SpelFunctionParam(name="skipByDefault", desc="flag indicating whether to skip by default when no relevant environment variables are set") boolean skipByDefault) @@ -383,46 +366,46 @@ public static final String fcliCmdSkipFromEnvReason( } return skipByDefault ? String.format("Set %s to 'true' to enable this step", doEnvName) : null; } - + @SpelFunction(cat=workflow, desc=""" For use with `run.fcli::skip.if-reason`, returns the given skip reason if `skip` is \ `true`, otherwise `null` is returned. - """, - returns="Skip reason or `null` if no reason to skip") + """, + returns="Skip reason or `null` if no reason to skip") public static final String skipReasonIf( @SpelFunctionParam(name="skip", desc="the condition indicating whether to skip") boolean skip, @SpelFunctionParam(name="reason", desc="the reason to return if skipping") String reason) { return skip ? reason : null; } - + @SpelFunction(cat=workflow, desc=""" For use with `run.fcli::skip.if-reason`, returns a skip reason if the given environment \ variable hasn't been set, otherwise `null` is returned. - """, - returns="Skip reason or `null` if no reason to skip") + """, + returns="Skip reason or `null` if no reason to skip") public static final String skipBlankEnvReason( - @SpelFunctionParam(name="", desc="the name of the environment variable to check") String envName) + @SpelFunctionParam(name="", desc="the name of the environment variable to check") String envName) { return StringUtils.isNotBlank(EnvHelper.env(envName)) ? null : String.format("%s not set", envName); } @SpelFunction(cat=workflow, - returns="The given fcli action name if it exists in the given fcli module, `null` otherwise.") + returns="The given fcli action name if it exists in the given fcli module, `null` otherwise.") public static final String actionOrNull( @SpelFunctionParam(name="moduleName", desc="fcli module to check for action existence") String moduleName, @SpelFunctionParam(name="actionName", desc="fcli action to check for existence") String actionName) { return ActionSpelFunctionsHelper.hasBuiltInAction(moduleName, actionName) ? actionName : null; } - + @SpelFunction(cat=workflow, desc = """ Replaces environment variable references in the given options string with the corresponding \ environment variable values, removing any options for which the environment variable doesn't \ exist or its value is blank. For example, given `--opt1=ENV1 --opt2=ENV2`, this function will \ return `"--opt1=SomeValue"` if `ENV1` is set to `SomeValue` and `ENV2` is either blank or doesn't \ - exist. - """, returns="") + exist. + """, returns="") public static final String optsFromEnv( @SpelFunctionParam(name="input", desc="options to be resolved from environment variables") String opts) { @@ -439,7 +422,7 @@ public static final String optsFromEnv( } return String.join(" ", output); } - + @SpelFunction(cat=workflow, desc = """ Returns a formatted option string in the form `"name=value"` if the value is not blank, \ or an empty string if the value is blank. This is useful for conditionally including \ @@ -447,20 +430,20 @@ public static final String optsFromEnv( """, returns="Formatted option string `\"name=value\"` if value is not blank, empty string otherwise") public static final String opt( - @SpelFunctionParam(name="name", desc="the option name") String name, + @SpelFunctionParam(name="name", desc="the option name") String name, @SpelFunctionParam(name="value", desc="the option value; if blank, function returns empty string") String value) { if ( StringUtils.isBlank(value) ) { return ""; } return String.format("\"%s=%s\"", name, value); } @SpelFunction(cat=workflow, - returns="Value of `_EXTRA_OPTS` environment variable, or empty string if not defined") + returns="Value of `_EXTRA_OPTS` environment variable, or empty string if not defined") public static final String extraOpts( @SpelFunctionParam(name="envPrefix", desc="the environment variable prefix used to construct the full `EXTRA_OPTS` variable name") String envPrefix) { return ActionSpelFunctionsHelper.envOrDefault(envPrefix, "EXTRA_OPTS", ""); } - + @SpelFunction(cat=util, desc = """ Converts the given object into an array of key-value pairs. For example, an object `{p1: v1, p2: v2}` \ will be converted into an array `[{key: p1, value: v1}, {key: p2, value: v2}`. This can for example be @@ -488,45 +471,45 @@ public static final ArrayNode properties( Creates an issue source file resolver that maps Fortify-reported paths to workspace-relative paths. \ Fortify may add or strip leading directories during scanning; this resolver uses longest-suffix \ matching to find the correct file in the workspace. - + Configuration properties: * `workspaceDir` - Repository root directory (required for path resolution) * `sourceDir` - Directory that was scanned (optional; used to prioritize matches when multiple files share the same name) - + Example: `${#issueSourceFileResolver({workspaceDir:\"/workspace\", sourceDir:\"/workspace/src\"})}` - + For backward compatibility, if only `sourceDir` is provided, it will be used as `workspaceDir`. - + See available methods via SpEL function documentation of the returned IssueSourceFileResolver object. """, returns="Issue source file resolver with resolve() and exists() methods", - renderSubFunctions=RenderSubFunctionsMode.INLINE) + renderSubFunctions=RenderSubFunctionsMode.INLINE) public static final IssueSourceFileResolver issueSourceFileResolver( - @SpelFunctionParam(name="config", desc="configuration; may contain `workspaceDir` (repo root) and/or `sourceDir` (scan directory for prioritization)") Map config) + @SpelFunctionParam(name="config", desc="configuration; may contain `workspaceDir` (repo root) and/or `sourceDir` (scan directory for prioritization)") Map config) { var workspaceDir = config.get("workspaceDir"); var sourceDir = config.get("sourceDir"); - + // For backward compatibility: if only sourceDir provided (old usage), use it as workspaceDir if (StringUtils.isBlank(workspaceDir) && StringUtils.isNotBlank(sourceDir)) { workspaceDir = sourceDir; sourceDir = null; // Don't use as sourcePath since it's also the workspace } - + var builder = IssueSourceFileResolver.builder() .workspacePath(StringUtils.isBlank(workspaceDir) ? null : Path.of(workspaceDir)) .sourcePath(StringUtils.isBlank(sourceDir) ? null : Path.of(sourceDir)); return builder.build(); } - @SpelFunction(cat=fortify, returns="normalized array of trace nodes") + @SpelFunction(cat=fortify, returns="normalized array of trace nodes") public static final ArrayNode normalizeTraceNodes( @SpelFunctionParam(name="input", desc="the original, non-normalized array of trace nodes") ArrayNode traceNodes) { return FortifyTraceNodeHelper.normalize(traceNodes); } - @SpelFunction(cat=fortify, returns="normalized and merged array of trace nodes") + @SpelFunction(cat=fortify, returns="normalized and merged array of trace nodes") public static final ArrayNode normalizeAndMergeTraceNodes( @SpelFunctionParam(name="input", desc="the original, non-normalized array of trace nodes") ArrayNode traceNodes) { @@ -552,115 +535,18 @@ public static final JsonNode fcliBuildProperties() { public static final String copyright() { return String.format("Copyright (c) %s Open Text", Year.now().getValue()); } - + @SpelFunction(cat=internal, desc=""" - Returns basic information about the local git repository for the given source directory, or null if the - directory is not inside a git working tree. Only constant-time lookups are performed (HEAD commit only). - Structure: - { - repository: { workspaceDir, remoteUrl?, name: { short, full? } }, - branch: { full?, short? }, - commit: { - id: { full, short }, - message: { short, full }, - author: { name, email, when }, - committer: { name, email, when } - } - } + (DEPRECATED) Use `#git.localRepo(...)` instead; this function is retained only for backward \ + compatibility and simply delegates to it. """, returns="Git repository information or null if not a git work dir", returnType=LocalRepoInfo.class) + @Deprecated public static final ObjectNode localRepo( @SpelFunctionParam(name="sourceDir", desc="directory assumed to be inside a git working tree") String sourceDir) { - if (StringUtils.isBlank(sourceDir)) { return null; } - var dir = Path.of(sourceDir).toAbsolutePath().normalize().toFile(); - if (!dir.exists()) { return null; } - FileRepositoryBuilder builder = new FileRepositoryBuilder().findGitDir(dir); - if (builder.getGitDir()==null) { return null; } - try (Repository repo = builder.build()) { - var mapper = JsonHelper.getObjectMapper(); - - // Repository information - var remote = ActionSpelFunctionsJGitHelper.selectRemote(repo); - var remoteUrl = remote==null?null:repo.getConfig().getString("remote", remote, "url"); - var names = ActionSpelFunctionsJGitHelper.deriveRepoNames(dir.getName(), remoteUrl); - var repository = CiRepository.builder() - .workspaceDir(repo.getWorkTree().getAbsolutePath()) - .remoteUrl(StringUtils.isBlank(remoteUrl) ? null : remoteUrl) - .name(CiRepositoryName.builder() - .short_(names[0]) - .full(names[1]) - .build()) - .build(); - - // Branch information - CiBranch branch = null; - try { - String fullBranch = repo.getFullBranch(); - if (fullBranch != null) { - branch = CiBranch.builder() - .full(fullBranch) - .short_(Repository.shortenRefName(fullBranch)) - .build(); - } - } catch (Exception e) { } - - // Commit information - CiCommit commit = null; - var headId = repo.resolve("HEAD"); - if (headId != null) { - try (var walk = new RevWalk(repo)) { - RevCommit gitCommit = walk.parseCommit(headId); - String shortId; - try { - var abbrev = repo.newObjectReader().abbreviate(gitCommit.getId(), 8); - shortId = abbrev.name(); - } catch (Exception ex) { - shortId = gitCommit.getId().getName().substring(0, 8); - } - - var authorIdent = gitCommit.getAuthorIdent(); - var committerIdent = gitCommit.getCommitterIdent(); - - var commitId = CiCommitId.builder() - .full(gitCommit.getId().getName()) - .short_(shortId) - .build(); - - commit = CiCommit.builder() - .headId(commitId) - .mergeId(commitId) // Same as headId for local repos - .message(CiCommitMessage.builder() - .short_(gitCommit.getShortMessage()) - .full(gitCommit.getFullMessage()) - .build()) - .author(authorIdent != null ? CiPerson.builder() - .name(authorIdent.getName()) - .email(authorIdent.getEmailAddress()) - .when(authorIdent.getWhenAsInstant().toString()) - .build() : null) - .committer(committerIdent != null ? CiPerson.builder() - .name(committerIdent.getName()) - .email(committerIdent.getEmailAddress()) - .when(committerIdent.getWhenAsInstant().toString()) - .build() : null) - .build(); - } catch (Exception e) { } - } - - // Build root object - var root = mapper.createObjectNode(); - root.set("repository", mapper.valueToTree(repository)); - if (branch != null) { - root.set("branch", mapper.valueToTree(branch)); - } - if (commit != null) { - root.set("commit", mapper.valueToTree(commit)); - } - - return root; - } catch (Exception e) { return null; } + return ActionGitSpelFunctions.INSTANCE.localRepo(sourceDir); } - + private static final class ActionSpelFunctionsJsoupHelper { private static final void replaceCode(Element e) { var text = e.text(); @@ -698,64 +584,15 @@ private static String documentToPlainText(Document document) { } } - private static final class ActionSpelFunctionsJGitHelper { - private static String selectRemote(Repository repo) { - try { - var remotes = repo.getRemoteNames(); - if (remotes==null || remotes.isEmpty()) { return null; } - if (remotes.contains("origin")) { return "origin"; } - return remotes.iterator().next(); - } catch (Exception e) { return null; } - } - - private static String[] deriveRepoNames(String fallbackShort, String remoteUrl) { - if (StringUtils.isBlank(remoteUrl)) { return new String[]{fallbackShort, null}; } - try { - var cleaned = remoteUrl.trim(); - if (cleaned.endsWith(".git")) { cleaned = cleaned.substring(0, cleaned.length()-4); } - String pathPart; - if (cleaned.startsWith("git@")) { - int idx = cleaned.indexOf(":"); - pathPart = idx>=0 ? cleaned.substring(idx+1) : cleaned; - } else { - try { - var uri = URI.create(cleaned); - pathPart = uri.getPath(); - if (pathPart==null) { pathPart = cleaned; } - } catch (Exception ex) { pathPart = cleaned; } - } - if (pathPart.startsWith("/")) { pathPart = pathPart.substring(1); } - if (pathPart.endsWith("/")) { pathPart = pathPart.substring(0, pathPart.length()-1); } - if (pathPart.contains("/")) { - var shortName = pathPart.substring(pathPart.lastIndexOf('/')+1); - return new String[]{shortName, pathPart}; - } - return new String[]{pathPart, pathPart}; - } catch (Exception e) { return new String[]{fallbackShort, null}; } - } - - } - - - private static final class ActionSpelFunctionsHelper { private static final String envOrDefault(String prefix, String suffix, String defaultValue) { var envName = String.format("%s_%s", prefix, suffix).toUpperCase().replace('-', '_'); var envValue = EnvHelper.env(envName); - return StringUtils.isNotBlank(envValue) ? envValue : defaultValue; + return StringUtils.isNotBlank(envValue) ? envValue : defaultValue; } - + private static boolean hasBuiltInAction(String moduleName, String actionName) { - if ( StringUtils.isBlank(actionName) ) { return false; } - return builtinActionNamesByModule - .computeIfAbsent(moduleName, ActionSpelFunctionsHelper::getBuiltinActionNames) - .contains(actionName); + return ActionLoaderHelper.hasBuiltInAction(moduleName, actionName); } - - private static final Set getBuiltinActionNames(String moduleName) { - return ActionLoaderHelper - .streamAsNames(ActionSource.defaultActionSources(moduleName), ActionValidationHandler.IGNORE) - .collect(Collectors.toSet()); - } - } -} \ No newline at end of file + } +} diff --git a/fcli-core/fcli-common-action/src/test/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistryTest.java b/fcli-core/fcli-common-action/src/test/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistryTest.java new file mode 100644 index 00000000000..bda2547c46d --- /dev/null +++ b/fcli-core/fcli-common-action/src/test/java/com/fortify/cli/common/action/helper/ci/ActionCiSpelFunctionsRegistryTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.action.helper.ci; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.common.action.model.Action; +import com.fortify.cli.common.action.runner.ActionRunnerConfig; +import com.fortify.cli.common.progress.helper.IProgressWriterI18n; + +public class ActionCiSpelFunctionsRegistryTest { + @Test + void testActionRunnerConfigRegistersCiInfoVariablesForCliDefaults() { + var config = ActionRunnerConfig.builder() + .action(new Action()) + .progressWriter(NoOpProgressWriterI18n.INSTANCE) + .onValidationErrors(r -> new RuntimeException("validation")) + .actionContextConfigurers(Collections.emptyList()) + .actionConfigSpelEvaluatorConfigurers(Collections.emptyList()) + .actionContextSpelEvaluatorConfigurers(Collections.emptyList()) + .defaultFcliRunOptions(Collections.emptyMap()) + .build(); + + var spelEvaluator = config.getSpelEvaluator(); + assertEquals("ado", spelEvaluator.evaluate("#ado.type", null, String.class)); + assertEquals("github", spelEvaluator.evaluate("#github.type", null, String.class)); + assertEquals("gitlab", spelEvaluator.evaluate("#gitlab.type", null, String.class)); + assertEquals("bitbucket", spelEvaluator.evaluate("#bitbucket.type", null, String.class)); + + // Should be safe to evaluate during CLI option parsing even when no CI env is detected. + assertNull(spelEvaluator.evaluate("#ado.env?.project", null, String.class)); + } + + private enum NoOpProgressWriterI18n implements IProgressWriterI18n { + INSTANCE; + + @Override + public boolean isMultiLineSupported() { + return false; + } + + @Override + public void writeProgress(String message, Object... args) {} + + @Override + public void writeInfo(String message, Object... args) {} + + @Override + public void writeInfoWithException(String message, Throwable cause, Object... args) {} + + @Override + public void writeWarning(String message, Object... args) {} + + @Override + public void writeWarningWithException(String message, Throwable cause, Object... args) {} + + @Override + public void clearProgress() {} + + @Override + public void close() {} + + @Override + public void writeI18nProgress(String keySuffix, Object... args) {} + + @Override + public void writeI18nWarning(String keySuffix, Object... args) {} + + @Override + public String type() { + return "test"; + } + } +} diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiEnvironmentTestHelper.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiEnvironmentTestHelper.java index 8d5d9666b88..f3b73c7ca89 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiEnvironmentTestHelper.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiEnvironmentTestHelper.java @@ -71,19 +71,27 @@ private static Set resolveAllEnvironmentVariableNames() { private static void collectEnvironmentVariableNames(Class environmentClass, Set target) { for ( Field field : environmentClass.getDeclaredFields() ) { - if ( Modifier.isStatic(field.getModifiers()) - && Modifier.isFinal(field.getModifiers()) - && field.getType().equals(String.class) - && field.getName().startsWith("ENV_") ) { - try { - field.setAccessible(true); + if ( !Modifier.isStatic(field.getModifiers()) || !Modifier.isFinal(field.getModifiers()) ) continue; + if ( !field.getName().startsWith("ENV_") ) continue; + try { + field.setAccessible(true); + if ( field.getType().equals(String.class) ) { var value = (String)field.get(null); if ( value != null ) { target.add(value); } - } catch ( IllegalAccessException e ) { - throw new FcliBugException("Unable to access CI environment field "+field.getName(), e); + } else if ( field.getType().equals(String[].class) ) { + var values = (String[])field.get(null); + if ( values != null ) { + for ( var value : values ) { + if ( value != null ) { + target.add(value); + } + } + } } + } catch ( IllegalAccessException e ) { + throw new FcliBugException("Unable to access CI environment field "+field.getName(), e); } } } diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.java new file mode 100644 index 00000000000..369d147faa1 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentials.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.ci; + +import org.apache.commons.lang3.StringUtils; + +/** + * Basic authentication credentials for pushing to a git remote. + * + * @param username basic-auth username + * @param token basic-auth token or password + */ +public record CiGitCredentials(String username, String token) { + public boolean isPresent() { + return StringUtils.isNotBlank(username) && StringUtils.isNotBlank(token); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java new file mode 100644 index 00000000000..246cd7e2ce4 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/CiGitCredentialsHelper.java @@ -0,0 +1,159 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.ci; + +import org.apache.commons.lang3.StringUtils; + +import com.fortify.cli.common.ci.ado.AdoEnvironment; +import com.fortify.cli.common.ci.bitbucket.BitbucketEnvironment; +import com.fortify.cli.common.ci.github.GitHubEnvironment; +import com.fortify.cli.common.ci.gitlab.GitLabEnvironment; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Resolves basic-auth credentials for authenticated Git operations (in particular {@code push}). + * The generic {@code GIT_PUSH_TOKEN} environment variable takes precedence, as CI-provided + * tokens may not carry push permissions. If not set, this falls back to the credentials detected + * for the currently active CI system, reusing the token detection already implemented in the + * individual {@code *Environment} classes. + */ +public final class CiGitCredentialsHelper { + /** Generic, CI-agnostic push token override. */ + public static final String ENV_GIT_PUSH_TOKEN = "GIT_PUSH_TOKEN"; + + private static final String GITHUB_USERNAME = "x-access-token"; + private static final String GITLAB_PAT_USERNAME = "oauth2"; + private static final String GITLAB_JOB_TOKEN_USERNAME = "gitlab-ci-token"; + private static final String ADO_USERNAME = "AzureDevOps"; + private static final String BITBUCKET_TOKEN_USERNAME = "x-token-auth"; + + private CiGitCredentialsHelper() {} + + /** + * Resolve the basic-auth credentials to use for pushing to the remote repository. + * + * @param remoteUrl remote URL of the git repository, used to infer a username when only a + * generic push token override is available + * @return credentials for push operations, or {@code null} if no credentials are available + */ + public static CiGitCredentials resolvePushCredentials(String remoteUrl) { + if (isSshUrl(remoteUrl)) { + return null; + } + + var explicitToken = EnvHelper.env(ENV_GIT_PUSH_TOKEN); + if (StringUtils.isNotBlank(explicitToken)) { + return new CiGitCredentials(resolveUsername(remoteUrl), explicitToken); + } + return resolveActiveCiCredentials(); + } + + private static CiGitCredentials resolveActiveCiCredentials() { + if (GitHubEnvironment.detect() != null) { + return credentials(GITHUB_USERNAME, EnvHelper.env(GitHubEnvironment.ENV_TOKEN)); + } + if (GitLabEnvironment.detect() != null) { + var token = EnvHelper.env(GitLabEnvironment.ENV_TOKEN); + if (StringUtils.isBlank(token)) { + return null; + } + var jobToken = EnvHelper.env(GitLabEnvironment.ENV_JOB_TOKEN); + var username = StringUtils.isNotBlank(jobToken) && jobToken.equals(token) + ? GITLAB_JOB_TOKEN_USERNAME : GITLAB_PAT_USERNAME; + return credentials(username, token); + } + if (AdoEnvironment.detect() != null) { + return credentials(ADO_USERNAME, EnvHelper.env(AdoEnvironment.ENV_TOKEN)); + } + if (BitbucketEnvironment.detect() != null) { + var username = EnvHelper.env(BitbucketEnvironment.ENV_USERNAME); + var token = EnvHelper.env(BitbucketEnvironment.ENV_APP_PASSWORD); + if (StringUtils.isNotBlank(username) && StringUtils.isNotBlank(token)) { + return credentials(username, token); + } + token = EnvHelper.env(BitbucketEnvironment.ENV_STEP_OAUTH_TOKEN); + if (StringUtils.isBlank(token)) { + token = EnvHelper.env(BitbucketEnvironment.ENV_TOKEN); + } + return credentials(BITBUCKET_TOKEN_USERNAME, token); + } + return null; + } + + private static CiGitCredentials credentials(String username, String token) { + return StringUtils.isNotBlank(token) ? new CiGitCredentials(username, token) : null; + } + + private static String resolveUsername(String remoteUrl) { + var activeCredentials = resolveActiveCiCredentials(); + if (activeCredentials != null && StringUtils.isNotBlank(activeCredentials.username())) { + return activeCredentials.username(); + } + switch (detectPlatformFromUrl(remoteUrl)) { + case "github": + return GITHUB_USERNAME; + case "gitlab": + return GITLAB_PAT_USERNAME; + case "ado": + return ADO_USERNAME; + case "bitbucket": + return BITBUCKET_TOKEN_USERNAME; + default: + return "git"; + } + } + + private static boolean isSshUrl(String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return false; + } + var url = remoteUrl.trim(); + return url.startsWith("git@") || url.startsWith("ssh://"); + } + + private static String detectPlatformFromUrl(String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return "unknown"; + } + try { + String host; + var cleaned = remoteUrl.trim(); + if (cleaned.startsWith("git@")) { + int colon = cleaned.indexOf(':'); + int at = cleaned.indexOf('@'); + host = (at >= 0 && colon > at) ? cleaned.substring(at + 1, colon) : null; + } else { + host = java.net.URI.create(cleaned).getHost(); + } + if (host == null) { + return "unknown"; + } + host = host.toLowerCase(); + if (host.equals("github.com") || host.endsWith(".github.com")) { + return "github"; + } + if (host.equals("gitlab.com") || host.contains("gitlab")) { + return "gitlab"; + } + if (host.contains("dev.azure.com") || host.contains("visualstudio.com") || host.contains("azure")) { + return "ado"; + } + if (host.contains("bitbucket")) { + return "bitbucket"; + } + } catch (Exception e) { + return "unknown"; + } + return "unknown"; + } +} diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/ado/AdoEnvironment.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/ado/AdoEnvironment.java index 9441596ca7a..47bd132678b 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/ado/AdoEnvironment.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/ado/AdoEnvironment.java @@ -47,8 +47,10 @@ public record AdoEnvironment( String organization, String project, String repositoryId, + String token, String buildId, String prTerminology, + String prKeyword, String ciName, String ciId ) { @@ -57,41 +59,43 @@ public record AdoEnvironment( public static final String NAME = "Azure DevOps"; public static final String ID = "ado"; public static final String PR_TERMINOLOGY = "Pull Request"; + public static final String PR_KEYWORD = "pr"; - // Environment variable names - public static final String ENV_ORGANIZATION_URL = "System.TeamFoundationCollectionUri"; - public static final String ENV_PROJECT = "System.TeamProject"; - public static final String ENV_REPOSITORY_NAME = "Build.Repository.Name"; - public static final String ENV_REPOSITORY_ID = "Build.Repository.ID"; - public static final String ENV_BUILD_ID = "Build.BuildId"; - public static final String ENV_SOURCE_BRANCH = "Build.SourceBranch"; - public static final String ENV_SOURCE_BRANCH_NAME = "Build.SourceBranchName"; - public static final String ENV_SOURCE_VERSION = "Build.SourceVersion"; - public static final String ENV_SOURCES_DIRECTORY = "Build.SourcesDirectory"; - public static final String ENV_DEFAULT_WORKING_DIRECTORY = "System.DefaultWorkingDirectory"; - public static final String ENV_PR_SOURCE_BRANCH = "System.PullRequest.SourceBranch"; - public static final String ENV_PR_SOURCE_BRANCH_NAME = "System.PullRequest.SourceBranchName"; - public static final String ENV_PR_TARGET_BRANCH = "System.PullRequest.TargetBranch"; - public static final String ENV_PR_TARGET_BRANCH_NAME = "System.PullRequest.TargetBranchName"; - public static final String ENV_PR_ID = "System.PullRequest.PullRequestId"; - public static final String ENV_TOKEN = "ADO_TOKEN"; + // Environment variable names (arrays for fallback lookup) + public static final String[] ENV_ORGANIZATION_URL = {"System.TeamFoundationCollectionUri", "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"}; + public static final String[] ENV_PROJECT = {"System.TeamProject", "SYSTEM_TEAMPROJECT"}; + public static final String[] ENV_REPOSITORY_NAME = {"Build.Repository.Name", "BUILD_REPOSITORY_NAME"}; + public static final String[] ENV_REPOSITORY_ID = {"Build.Repository.ID", "BUILD_REPOSITORY_ID"}; + public static final String[] ENV_BUILD_ID = {"Build.BuildId", "BUILD_BUILDID"}; + public static final String[] ENV_SOURCE_BRANCH = {"Build.SourceBranch", "BUILD_SOURCEBRANCH"}; + public static final String[] ENV_SOURCE_BRANCH_NAME = {"Build.SourceBranchName", "BUILD_SOURCEBRANCHNAME"}; + public static final String[] ENV_SOURCE_VERSION = {"Build.SourceVersion", "BUILD_SOURCEVERSION"}; + public static final String[] ENV_SOURCES_DIRECTORY = {"Build.SourcesDirectory", "BUILD_SOURCESDIRECTORY"}; + public static final String[] ENV_DEFAULT_WORKING_DIRECTORY = {"System.DefaultWorkingDirectory", "SYSTEM_DEFAULTWORKINGDIRECTORY"}; + public static final String[] ENV_PR_SOURCE_BRANCH = {"System.PullRequest.SourceBranch", "SYSTEM_PULLREQUEST_SOURCEBRANCH"}; + public static final String[] ENV_PR_SOURCE_BRANCH_NAME = {"System.PullRequest.SourceBranchName", "SYSTEM_PULLREQUEST_SOURCEBRANCHNAME"}; + public static final String[] ENV_PR_TARGET_BRANCH = {"System.PullRequest.TargetBranch", "SYSTEM_PULLREQUEST_TARGETBRANCH"}; + public static final String[] ENV_PR_TARGET_BRANCH_NAME = {"System.PullRequest.TargetBranchName", "SYSTEM_PULLREQUEST_TARGETBRANCHNAME"}; + public static final String[] ENV_PR_ID = {"System.PullRequest.PullRequestId", "SYSTEM_PULLREQUEST_PULLREQUESTID"}; + public static final String[] ENV_TOKEN = {"ADO_TOKEN", "SYSTEM_ACCESSTOKEN"}; /** * Detect Azure DevOps CI environment from environment variables. * Returns null if not running in Azure DevOps. */ public static AdoEnvironment detect() { - var repoName = EnvHelper.env(ENV_REPOSITORY_NAME); - if (StringUtils.isBlank(repoName)) return null; - - var sourceBranchRaw = EnvHelper.env(ENV_SOURCE_BRANCH); + var orgUrl = env(ENV_ORGANIZATION_URL); + if (StringUtils.isBlank(orgUrl)) return null; + + var repoName = env(ENV_REPOSITORY_NAME); + var sourceBranchRaw = env(ENV_SOURCE_BRANCH); var isPr = StringUtils.isNotBlank(sourceBranchRaw) && sourceBranchRaw.startsWith("refs/pull/"); var branchInfo = detectBranchInfo(isPr, sourceBranchRaw); var sourceBranch = branchInfo[0]; var targetBranch = branchInfo[1]; - var sha = EnvHelper.env(ENV_SOURCE_VERSION); - var repositoryId = EnvHelper.env(ENV_REPOSITORY_ID); - var buildId = EnvHelper.env(ENV_BUILD_ID); + var sha = env(ENV_SOURCE_VERSION); + var repositoryId = env(ENV_REPOSITORY_ID); + var buildId = env(ENV_BUILD_ID); // Build standardized structures // Extract simple repo name from full path if present @@ -103,8 +107,7 @@ public static AdoEnvironment detect() { } var ciRepository = CiRepository.builder() - .workspaceDir(EnvHelper.envOrDefault(ENV_SOURCES_DIRECTORY, - EnvHelper.envOrDefault(ENV_DEFAULT_WORKING_DIRECTORY, "."))) + .workspaceDir(StringUtils.defaultIfBlank(env(ENV_SOURCES_DIRECTORY, ENV_DEFAULT_WORKING_DIRECTORY), ".")) .remoteUrl(null) // Not readily available in environment .name(CiRepositoryName.builder() .short_(shortRepoName) @@ -132,19 +135,21 @@ public static AdoEnvironment detect() { .build(); var pullRequest = isPr - ? CiPullRequest.active(EnvHelper.env(ENV_PR_ID), targetBranch) + ? CiPullRequest.active(env(ENV_PR_ID), targetBranch) : CiPullRequest.inactive(); return AdoEnvironment.builder() - .organization(EnvHelper.env(ENV_ORGANIZATION_URL)) - .project(EnvHelper.env(ENV_PROJECT)) + .organization(orgUrl) + .project(env(ENV_PROJECT)) .repositoryId(repositoryId) + .token(env(ENV_TOKEN)) .buildId(buildId) .ciRepository(ciRepository) .ciBranch(ciBranch) .ciCommit(ciCommit) .pullRequest(pullRequest) .prTerminology(PR_TERMINOLOGY) + .prKeyword(PR_KEYWORD) .ciName(NAME) .ciId(ID) .build(); @@ -159,21 +164,25 @@ private static String[] detectBranchInfo(boolean isPr, String sourceBranchRaw) { String targetBranch; if (isPr) { - sourceBranch = EnvHelper.envOrDefault(ENV_PR_SOURCE_BRANCH, - EnvHelper.env(ENV_PR_SOURCE_BRANCH_NAME)); + sourceBranch = env(ENV_PR_SOURCE_BRANCH, ENV_PR_SOURCE_BRANCH_NAME); sourceBranch = StringUtils.isNotBlank(sourceBranch) ? sourceBranch.replaceAll("^refs/heads/", "") : null; - targetBranch = EnvHelper.envOrDefault(ENV_PR_TARGET_BRANCH, - EnvHelper.env(ENV_PR_TARGET_BRANCH_NAME)); + targetBranch = env(ENV_PR_TARGET_BRANCH, ENV_PR_TARGET_BRANCH_NAME); targetBranch = StringUtils.isNotBlank(targetBranch) ? targetBranch.replaceAll("^refs/heads/", "") : null; } else { - sourceBranch = EnvHelper.envOrDefault(ENV_SOURCE_BRANCH_NAME, - StringUtils.isNotBlank(sourceBranchRaw) ? sourceBranchRaw.replaceAll("^refs/heads/", "") : null); + sourceBranch = env(ENV_SOURCE_BRANCH_NAME); + if (StringUtils.isBlank(sourceBranch) && StringUtils.isNotBlank(sourceBranchRaw)) { + sourceBranch = sourceBranchRaw.replaceAll("^refs/heads/", ""); + } targetBranch = null; } return new String[]{sourceBranch, targetBranch}; } + + private static String env(String[]... envNames) { + return EnvHelper.env(envNames); + } /** * Get qualified repository name for Fortify (repo:branch format). diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/bitbucket/BitbucketEnvironment.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/bitbucket/BitbucketEnvironment.java index 003b65a6001..d72dd24e1b8 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/bitbucket/BitbucketEnvironment.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/bitbucket/BitbucketEnvironment.java @@ -44,6 +44,7 @@ public record BitbucketEnvironment( String repositoryFullName, String pipelineUuid, String prTerminology, + String prKeyword, String ciName, String ciId ) { @@ -51,6 +52,7 @@ public record BitbucketEnvironment( public static final String NAME = "Bitbucket"; public static final String ID = "bitbucket"; public static final String PR_TERMINOLOGY = "Pull Request"; + public static final String PR_KEYWORD = "pr"; public static final String ENV_WORKSPACE = "BITBUCKET_WORKSPACE"; public static final String ENV_REPO_OWNER = "BITBUCKET_REPO_OWNER"; @@ -138,6 +140,7 @@ public static BitbucketEnvironment detect() { .repositoryFullName(repoFullName) .pipelineUuid(EnvHelper.env(ENV_PIPELINE_UUID)) .prTerminology(PR_TERMINOLOGY) + .prKeyword(PR_KEYWORD) .ciName(NAME) .ciId(ID) .build(); diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubEnvironment.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubEnvironment.java index f12f0ac5477..fbece672f05 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubEnvironment.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubEnvironment.java @@ -50,8 +50,13 @@ public record GitHubEnvironment( CiCommit ciCommit, CiPullRequest pullRequest, // GitHub-specific properties + String apiUrl, + String token, + String repositoryOwner, + String repository, String jobSummaryFile, String prTerminology, + String prKeyword, String ciName, String ciId @@ -63,6 +68,7 @@ public record GitHubEnvironment( public static final String NAME = "GitHub"; public static final String ID = "github"; public static final String PR_TERMINOLOGY = "Pull Request"; + public static final String PR_KEYWORD = "pr"; // Environment variable names public static final String ENV_REPOSITORY = "GITHUB_REPOSITORY"; @@ -133,12 +139,17 @@ public static GitHubEnvironment detect() { : CiPullRequest.inactive(); return GitHubEnvironment.builder() + .apiUrl(EnvHelper.env(ENV_API_URL)) + .token(EnvHelper.env(ENV_TOKEN)) + .repositoryOwner(repoParts.length > 1 ? repoParts[0] : null) + .repository(repo) .jobSummaryFile(EnvHelper.env(ENV_STEP_SUMMARY)) .ciRepository(ciRepository) .ciBranch(ciBranch) .ciCommit(ciCommit) .pullRequest(pullRequest) .prTerminology(PR_TERMINOLOGY) + .prKeyword(PR_KEYWORD) .ciName(NAME) .ciId(ID) .build(); diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java index ee7159fd2d9..b9d0a3810c3 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/github/GitHubRepo.java @@ -293,6 +293,30 @@ public ObjectNode createReviewComment(String pullNumber, String commitId, .getBody(); } + /** + * Create a pull request. + * + * @param title Pull request title + * @param head The name of the branch where your changes are implemented + * @param base The name of the branch you want the changes pulled into + * @param body Pull request description (Markdown supported) + * @return Created pull request object + */ + public ObjectNode createPullRequest(String title, String head, String base, String body) { + var requestBody = JsonHelper.getObjectMapper().createObjectNode() + .put("title", title) + .put("head", head) + .put("base", base) + .put("body", body); + + return unirest + .post("/repos/{owner}/{repo}/pulls") + .routeParam("owner", owner) + .routeParam("repo", repo) + .body(requestBody) + .asObject(ObjectNode.class) + .getBody(); + } // === Branch and Commit Operations === /** diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironment.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironment.java index c9f2bdc4e3e..c2792d752d2 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironment.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironment.java @@ -44,9 +44,12 @@ public record GitLabEnvironment( CiCommit ciCommit, CiPullRequest pullRequest, // GitLab-specific properties + String apiV4Url, + String token, String projectId, String pipelineId, String prTerminology, + String prKeyword, String ciName, String ciId ) { @@ -55,6 +58,7 @@ public record GitLabEnvironment( public static final String NAME = "GitLab"; public static final String ID = "gitlab"; public static final String PR_TERMINOLOGY = "Merge Request"; + public static final String PR_KEYWORD = "mr"; // Environment variable names public static final String ENV_GITLAB_CI = "GITLAB_CI"; @@ -71,8 +75,9 @@ public record GitLabEnvironment( public static final String ENV_REPOSITORY_URL = "CI_REPOSITORY_URL"; public static final String ENV_SERVER_URL = "CI_SERVER_URL"; // Base GitLab URL public static final String ENV_API_V4_URL = "CI_API_V4_URL"; // API v4 URL - public static final String ENV_TOKEN = "GITLAB_TOKEN"; - public static final String ENV_JOB_TOKEN = "CI_JOB_TOKEN"; // Built-in job token (automatic) + public static final String ENV_JOB_TOKEN = "CI_JOB_TOKEN"; // Built-in job token + // Job token is built-in and automatic, but can be overridden by GITLAB_TOKEN or GITLAB_API_TOKEN + public static final String[] ENV_TOKEN = {"GITLAB_TOKEN", "GITLAB_API_TOKEN", ENV_JOB_TOKEN}; /** * Detect GitLab CI environment from environment variables. @@ -134,6 +139,8 @@ public static GitLabEnvironment detect() { var pipelineIdValue = EnvHelper.env(ENV_PIPELINE_ID); return GitLabEnvironment.builder() + .apiV4Url(EnvHelper.env(ENV_API_V4_URL)) + .token(EnvHelper.env(ENV_TOKEN)) .projectId(projectIdStr) .pipelineId(StringUtils.isNotBlank(pipelineIdValue) ? pipelineIdValue : null) .ciRepository(ciRepository) @@ -141,6 +148,7 @@ public static GitLabEnvironment detect() { .ciCommit(ciCommit) .pullRequest(pullRequest) .prTerminology(PR_TERMINOLOGY) + .prKeyword(PR_KEYWORD) .ciName(NAME) .ciId(ID) .build(); diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java index 895765256fd..7b1d0c4283c 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabProject.java @@ -134,6 +134,29 @@ public ObjectNode createMergeRequestNote(String mergeRequestIid, String body) { .getBody(); } + /** + * Create a merge request. + * + * @param title Merge request title + * @param sourceBranch The source branch + * @param targetBranch The target branch + * @param description Merge request description (Markdown supported) + * @return Created merge request object + */ + public ObjectNode createMergeRequest(String title, String sourceBranch, String targetBranch, String description) { + var requestBody = JsonHelper.getObjectMapper().createObjectNode() + .put("title", title) + .put("source_branch", sourceBranch) + .put("target_branch", targetBranch) + .put("description", description); + + return unirest + .post("/projects/{id}/merge_requests") + .routeParam("id", projectId) + .body(requestBody) + .asObject(ObjectNode.class) + .getBody(); + } // === Branch and Commit Operations === /** diff --git a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabUnirestInstanceSupplier.java b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabUnirestInstanceSupplier.java index f22056c8bbb..747966f4dad 100644 --- a/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabUnirestInstanceSupplier.java +++ b/fcli-core/fcli-common-ci/src/main/java/com/fortify/cli/common/ci/gitlab/GitLabUnirestInstanceSupplier.java @@ -80,10 +80,7 @@ public static GitLabUnirestInstanceSupplier fromEnv(UnirestContext unirestContex .urlConfig(UrlConfig.builder() .url(EnvHelper.envOrDefault(GitLabEnvironment.ENV_API_V4_URL, "https://gitlab.com")) .build()) - .token(StringUtils.firstNonBlank( - EnvHelper.env(GitLabEnvironment.ENV_TOKEN), // Custom GITLAB_TOKEN (highest priority) - EnvHelper.env(GitLabEnvironment.ENV_JOB_TOKEN) // Built-in CI_JOB_TOKEN (automatic fallback) - )) + .token(EnvHelper.env(GitLabEnvironment.ENV_TOKEN)) .configuredFromEnv(true) .build(); } diff --git a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.java b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.java new file mode 100644 index 00000000000..8026d5d4ad3 --- /dev/null +++ b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/CiGitCredentialsHelperTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.ci; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +public class CiGitCredentialsHelperTest { + @AfterEach + void clearEnvironment() { + CiEnvironmentTestHelper.clearAllCiEnvironmentVariables(); + System.clearProperty("fcli.env." + CiGitCredentialsHelper.ENV_GIT_PUSH_TOKEN); + } + + @Test + void resolvePushCredentialsUsesGitHubToken() { + System.setProperty("fcli.env.GITHUB_REPOSITORY", "owner/repo"); + System.setProperty("fcli.env.GITHUB_TOKEN", "gh-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://github.com/fortify/fcli.git"); + + assertNotNull(credentials); + assertEquals("x-access-token", credentials.username()); + assertEquals("gh-token", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesGitLabJobTokenUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.CI_JOB_TOKEN", "gl-job-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("gitlab-ci-token", credentials.username()); + assertEquals("gl-job-token", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesGitLabPatUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.GITLAB_TOKEN", "gl-pat"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("oauth2", credentials.username()); + assertEquals("gl-pat", credentials.token()); + } + + @Test + void resolvePushCredentialsUsesExplicitOverrideWithDetectedUsername() { + System.setProperty("fcli.env.GITLAB_CI", "true"); + System.setProperty("fcli.env.CI_PROJECT_ID", "123"); + System.setProperty("fcli.env.CI_JOB_TOKEN", "gl-job-token"); + System.setProperty("fcli.env.GIT_PUSH_TOKEN", "override-token"); + + var credentials = CiGitCredentialsHelper.resolvePushCredentials("https://gitlab.example.com/group/repo.git"); + + assertNotNull(credentials); + assertEquals("gitlab-ci-token", credentials.username()); + assertEquals("override-token", credentials.token()); + } + + @Test + void resolvePushCredentialsReturnsNullForSshRemote() { + System.setProperty("fcli.env.GITHUB_REPOSITORY", "owner/repo"); + System.setProperty("fcli.env.GITHUB_TOKEN", "gh-token"); + + assertNull(CiGitCredentialsHelper.resolvePushCredentials("git@github.com:fortify/fcli.git")); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/ado/AdoEnvironmentTest.java b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/ado/AdoEnvironmentTest.java index e95d11f55bb..8b60337694b 100644 --- a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/ado/AdoEnvironmentTest.java +++ b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/ado/AdoEnvironmentTest.java @@ -48,6 +48,7 @@ void testDetectRegularCommit() { System.setProperty("fcli.env.Build.SourceBranchName", "main"); System.setProperty("fcli.env.Build.SourceVersion", "9876543210abcdef9876543210abcdef98765432"); System.setProperty("fcli.env.Build.SourcesDirectory", "/home/vsts/work/1/s"); + System.setProperty("fcli.env.ADO_TOKEN", "ado-token-value"); var env = AdoEnvironment.detect(); @@ -55,6 +56,7 @@ void testDetectRegularCommit() { assertEquals("https://dev.azure.com/myorg/", env.organization()); assertEquals("MyProject", env.project()); assertEquals("11111111-2222-3333-4444-555555555555", env.repositoryId()); + assertEquals("ado-token-value", env.token()); assertEquals("101", env.buildId()); assertNotNull(env.ciRepository()); @@ -129,9 +131,37 @@ void testDetectPullRequestAlternativeEnvVars() { assertEquals("bugfix-abc", env.ciBranch().short_()); assertEquals("release", env.pullRequest().target()); } + + @Test + void testDetectPullRequestUppercaseEnvVars() { + System.setProperty("fcli.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", "https://dev.azure.com/myorg/"); + System.setProperty("fcli.env.SYSTEM_TEAMPROJECT", "MyProject"); + System.setProperty("fcli.env.BUILD_REPOSITORY_NAME", "MyRepo"); + System.setProperty("fcli.env.BUILD_REPOSITORY_ID", "ffffffff-1111-2222-3333-aaaaaaaaaaaa"); + System.setProperty("fcli.env.BUILD_BUILDID", "404"); + System.setProperty("fcli.env.BUILD_SOURCEBRANCH", "refs/pull/999/merge"); + System.setProperty("fcli.env.BUILD_SOURCEVERSION", "feedbeef1234"); + System.setProperty("fcli.env.SYSTEM_PULLREQUEST_SOURCEBRANCHNAME", "feature-uppercase"); + System.setProperty("fcli.env.SYSTEM_PULLREQUEST_TARGETBRANCHNAME", "main"); + System.setProperty("fcli.env.SYSTEM_PULLREQUEST_PULLREQUESTID", "999"); + System.setProperty("fcli.env.BUILD_SOURCESDIRECTORY", "/home/vsts/work/1/s"); + + var env = AdoEnvironment.detect(); + + assertNotNull(env); + assertEquals("MyProject", env.project()); + assertEquals("ffffffff-1111-2222-3333-aaaaaaaaaaaa", env.repositoryId()); + assertEquals("404", env.buildId()); + assertEquals("feature-uppercase", env.ciBranch().short_()); + assertEquals(true, env.pullRequest().active()); + assertEquals("999", env.pullRequest().id()); + assertEquals("main", env.pullRequest().target()); + assertEquals("/home/vsts/work/1/s", env.ciRepository().workspaceDir()); + } @Test void testGetQualifiedRepoName() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "ProductRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/heads/staging"); System.setProperty("fcli.env.Build.SourceBranchName", "staging"); @@ -145,6 +175,7 @@ void testGetQualifiedRepoName() { @Test void testGetBranchForVersioning() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "ProductRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/heads/release-2.0"); System.setProperty("fcli.env.Build.SourceBranchName", "release-2.0"); @@ -158,6 +189,7 @@ void testGetBranchForVersioning() { @Test void testGetBranchForVersioningInPullRequest() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "ProductRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/pull/789/merge"); System.setProperty("fcli.env.Build.SourceVersion", "abc123"); @@ -173,6 +205,7 @@ void testGetBranchForVersioningInPullRequest() { @Test void testRepositoryNameWithPath() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "team/subteam/MyRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/heads/main"); System.setProperty("fcli.env.Build.SourceVersion", "abc123"); @@ -186,6 +219,7 @@ void testRepositoryNameWithPath() { @Test void testDefaultWorkingDirectoryFallback() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "MyRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/heads/main"); System.setProperty("fcli.env.Build.SourceVersion", "abc123"); @@ -199,9 +233,15 @@ void testDefaultWorkingDirectoryFallback() { @Test void testFallbackToCurrentDirectory() { + System.setProperty("fcli.env.System.TeamFoundationCollectionUri", "https://dev.azure.com/myorg/"); System.setProperty("fcli.env.Build.Repository.Name", "MyRepo"); System.setProperty("fcli.env.Build.SourceBranch", "refs/heads/main"); System.setProperty("fcli.env.Build.SourceVersion", "abc123"); + // Explicitly blank out directory vars to prevent real ADO agent env vars from leaking in + System.setProperty("fcli.env.Build.SourcesDirectory", ""); + System.setProperty("fcli.env.BUILD_SOURCESDIRECTORY", ""); + System.setProperty("fcli.env.System.DefaultWorkingDirectory", ""); + System.setProperty("fcli.env.SYSTEM_DEFAULTWORKINGDIRECTORY", ""); var env = AdoEnvironment.detect(); diff --git a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/github/GitHubEnvironmentTest.java b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/github/GitHubEnvironmentTest.java index b637baa134c..4b7bb9b0aad 100644 --- a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/github/GitHubEnvironmentTest.java +++ b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/github/GitHubEnvironmentTest.java @@ -44,11 +44,17 @@ void testDetectRegularCommit() { System.setProperty("fcli.env.GITHUB_SHA", "1234567890abcdef1234567890abcdef12345678"); System.setProperty("fcli.env.GITHUB_WORKSPACE", "/workspace"); System.setProperty("fcli.env.GITHUB_STEP_SUMMARY", "/tmp/summary.md"); + System.setProperty("fcli.env.GITHUB_API_URL", "https://api.github.example.com"); + System.setProperty("fcli.env.GITHUB_TOKEN", "gh-token"); var env = GitHubEnvironment.detect(); assertNotNull(env); assertEquals("/tmp/summary.md", env.jobSummaryFile()); + assertEquals("https://api.github.example.com", env.apiUrl()); + assertEquals("gh-token", env.token()); + assertEquals("owner", env.repositoryOwner()); + assertEquals("repo", env.repository()); assertNotNull(env.ciRepository()); assertEquals("/workspace", env.ciRepository().workspaceDir()); diff --git a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironmentTest.java b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironmentTest.java index e1bf116fc0a..8272aa80925 100644 --- a/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironmentTest.java +++ b/fcli-core/fcli-common-ci/src/test/java/com/fortify/cli/common/ci/gitlab/GitLabEnvironmentTest.java @@ -47,10 +47,14 @@ void testDetectRegularCommit() { System.setProperty("fcli.env.CI_COMMIT_BRANCH", "develop"); System.setProperty("fcli.env.CI_PIPELINE_ID", "9876"); System.setProperty("fcli.env.CI_REPOSITORY_URL", "https://gitlab.example.com/group/myproject.git"); + System.setProperty("fcli.env.CI_API_V4_URL", "https://gitlab.example.com/api/v4"); + System.setProperty("fcli.env.GITLAB_API_TOKEN", "gl-api-token"); var env = GitLabEnvironment.detect(); assertNotNull(env); + assertEquals("https://gitlab.example.com/api/v4", env.apiV4Url()); + assertEquals("gl-api-token", env.token()); assertEquals("12345", env.projectId()); assertEquals("9876", env.pipelineId()); diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/SpelFunctionsStandard.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/SpelFunctionsStandard.java index d6d53d5b8ca..b3bbc0ebafa 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/SpelFunctionsStandard.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/SpelFunctionsStandard.java @@ -14,14 +14,19 @@ import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.date; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.fcli; +import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.http; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.txt; import static com.fortify.cli.common.spel.fn.descriptor.annotation.SpelFunction.SpelFunctionCategory.util; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeParseException; import java.util.Arrays; +import java.util.Base64; import java.util.Collection; import java.util.List; import java.util.Objects; @@ -57,11 +62,11 @@ public class SpelFunctionsStandard { @SpelFunction(cat=txt, returns="`true` if given string is null or blank, `false` otherwise") public static final boolean isBlank( - @SpelFunctionParam(name="input", desc="the string to check") String s) + @SpelFunctionParam(name="input", desc="the string to check") String s) { return StringUtils.isBlank(s); } - + @SpelFunction(cat=txt, returns="The first string if it's not blank, otherwise the second string") public static final String ifBlank( @SpelFunctionParam(name="input", desc="the string to return if not blank") String s1, @@ -69,17 +74,17 @@ public static final String ifBlank( { return StringUtils.defaultIfBlank(s1, s2); } - + @SpelFunction(cat=txt, returns="`false` if given string is null or blank, `true` otherwise") public static final boolean isNotBlank( - @SpelFunctionParam(name="input", desc="the string to check") String s) + @SpelFunctionParam(name="input", desc="the string to check") String s) { return StringUtils.isNotBlank(s); } @SpelFunction(cat=txt, returns="The substring before the first occurrence of the separator, or `null` if input string is `null`") public static final String substringBefore( - @SpelFunctionParam(name="input", desc="the string to get a substring from") String s, + @SpelFunctionParam(name="input", desc="the string to get a substring from") String s, @SpelFunctionParam(name="separator", desc="the separator to search for") String separator) { return StringUtils.substringBefore(s, separator); @@ -87,8 +92,8 @@ public static final String substringBefore( @SpelFunction(cat=txt, returns="The substring after the first occurrence of the separator, or `null` if input string is `null`") public static final String substringAfter( - @SpelFunctionParam(name="input", desc="the string to get a substring from") String s, - @SpelFunctionParam(name="separator", desc="the separator to search for") String separator) + @SpelFunctionParam(name="input", desc="the string to get a substring from") String s, + @SpelFunctionParam(name="separator", desc="the separator to search for") String separator) { return StringUtils.substringAfter(s, separator); } @@ -111,7 +116,7 @@ public static final String indent( var text = (input instanceof JsonNode j) ? j.asText() : input.toString(); return StringHelper.indent(text, prefix); } - + @SpelFunction(cat=txt, returns="String consisting of the joined elements, separated by the given delimiter") public static final String join( @SpelFunctionParam(name="delimiter", desc="the delimiter to be used between each element") String delimiter, @@ -124,21 +129,21 @@ public static final String join( } else if (source instanceof ArrayNode) { stream = JsonHelper.stream((ArrayNode) source); } - return stream == null - ? "" + return stream == null + ? "" : stream.filter(Objects::nonNull).map(SpelFunctionsStandard::toString) .collect(Collectors.joining(delimiter)); } - + @SpelFunction(cat=txt, returns="String consisting of the joined elements separated by the given delimiter _if all elements are non-null_; otherwise `null`") public static final String joinOrNull( @SpelFunctionParam(name="delimiter", desc="the delimiter to be used between each element") String delimiter, - @SpelFunctionParam(name="input", desc="the elements to join") String... parts) + @SpelFunctionParam(name="input", desc="the elements to join") String... parts) { if (parts == null || Arrays.asList(parts).stream().anyMatch(Objects::isNull)) {return null;} return String.join(delimiter, parts); } - + @SpelFunction(cat=txt, desc = "Returns a literal regex pattern string for the given input string, escaping any characters that have a special meaning in regular expressions.", returns="The regex-quoted string") public static final String regexQuote( @@ -146,7 +151,7 @@ public static final String regexQuote( { return Pattern.quote(s); } - + @SpelFunction(cat=txt, desc = """ Replaces all occurrences in the input string based on regex patterns and replacement values provided in the mapping object. @@ -170,9 +175,9 @@ public static final String replaceAllFromRegExMap( } return s; } - + @SpelFunction(cat=txt, desc = "Generates a numbered list from the given list of elements.", - returns="Numbered list of input elements, each on a new line") + returns="Numbered list of input elements, each on a new line") public static final String numberedList( @SpelFunctionParam(name="input", desc="the list of elements to be numbered and joined") List elts) { @@ -188,11 +193,81 @@ public static final boolean isDebugEnabled() { return DebugHelper.isDebugEnabled(); } - @SpelFunction(cat=util, returns="A randomly generated UUID string in standard 36-character format") + @SpelFunction(cat=util, returns="A randomly generated UUID string in standard 36-character format") public static final String uuid() { return UUID.randomUUID().toString(); } + @SpelFunction(cat=http, desc = "Builds an HTTP Basic Authorization header value from username and password.", + returns="Authorization header value in the format `Basic `") + public static final String basicAuth( + @SpelFunctionParam(name="username", desc="the username to include in the basic auth credential pair") String username, + @SpelFunctionParam(name="password", desc="the password to include in the basic auth credential pair") String password) + { + var pair = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(password); + var encoded = Base64.getEncoder().encodeToString(pair.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } + + @SpelFunction(cat=http, desc = "Builds an HTTP Bearer Authorization header value from the given token.", + returns="Authorization header value in the format `Bearer `") + public static final String bearerAuth( + @SpelFunctionParam(name="token", desc="the bearer token") String token) + { + return "Bearer " + StringUtils.defaultString(token); + } + + @SpelFunction(cat=http, returns="URI-component encoded string using UTF-8, or `null` if input is `null`") + public static final String urlEncode( + @SpelFunctionParam(name="input", desc="the string to URI-component encode") String s) + { + if (s == null) { + return null; + } + return URLEncoder.encode(s, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("%7E", "~"); + } + + @SpelFunction(cat=http, returns="UTF-8 decoded string from URI-component encoded input, or `null` if input is `null`") + public static final String urlDecode( + @SpelFunctionParam(name="input", desc="the URI-component encoded string to decode") String s) + { + if (s == null) { + return null; + } + try { + return URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new FcliSimpleException("Invalid URI-encoded input passed to #urlDecode"); + } + } + + @SpelFunction(cat=util, returns="Base64-encoded representation of the input string using UTF-8, or `null` if input is `null`") + public static final String base64Encode( + @SpelFunctionParam(name="input", desc="the string to encode as Base64") String s) + { + if (s == null) { + return null; + } + return Base64.getEncoder().encodeToString(s.getBytes(StandardCharsets.UTF_8)); + } + + @SpelFunction(cat=util, returns="UTF-8 decoded string from the given Base64 input, or `null` if input is `null`") + public static final String base64Decode( + @SpelFunctionParam(name="input", desc="the Base64-encoded string to decode") String s) + { + if (s == null) { + return null; + } + try { + var decodedBytes = Base64.getDecoder().decode(s); + return new String(decodedBytes, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new FcliSimpleException("Invalid Base64 input passed to #base64Decode: " + s); + } + } + @SpelFunction(cat=txt, desc = """ Formats a string using the specified format string and arguments, returning the formatted string. \ See https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Formatter.html#syntax \ @@ -309,7 +384,7 @@ public static final String jsonStringify( throw new FcliTechnicalException("Error converting object to JSON string", e); } } - + private static final String toString(Object o) { if ( o==null ) { return ""; diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/SpelFunctionDescriptorsFactory.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/SpelFunctionDescriptorsFactory.java index 435b22d8466..03a26fc22ab 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/SpelFunctionDescriptorsFactory.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/SpelFunctionDescriptorsFactory.java @@ -50,7 +50,7 @@ public static final ArrayListWithAsJsonMethod getStandar } public static final ArrayListWithAsJsonMethod getActionSpelFunctionsDescriptors() { - // FoD & SSC classes are only available at runtime, so we need to specify them by name + // FoD, SSC, and action classes are only available at runtime, so we need to specify them by name return getSpelFunctionsDescriptors( "com.fortify.cli.common.spel.fn.SpelFunctionsStandard", "com.fortify.cli.common.action.runner.ActionSpelFunctions", diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/annotation/SpelFunction.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/annotation/SpelFunction.java index 6bdb9153ce3..988036dba95 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/annotation/SpelFunction.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/spel/fn/descriptor/annotation/SpelFunction.java @@ -37,8 +37,8 @@ * Example: github.repo() with SECTION creates section "repo" with uploadSarif, etc. */ RenderSubFunctionsMode renderSubFunctions() default RenderSubFunctionsMode.AUTO; - + public static enum SpelFunctionCategory { - txt, date, workflow, fortify, fcli, util, ci, internal + txt, date, workflow, fortify, fcli, util, http, ci, internal } } diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/EnvHelper.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/EnvHelper.java index d2d50cbafd5..1e48b177c5d 100644 --- a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/EnvHelper.java +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/EnvHelper.java @@ -16,6 +16,7 @@ import org.apache.commons.lang3.StringUtils; +import com.fortify.cli.common.exception.FcliBugException; import com.fortify.cli.common.exception.FcliSimpleException; public final class EnvHelper { @@ -89,6 +90,37 @@ public static final String env(String name) { return System.getProperty(envSystemPropertyName(name), System.getenv(name)); } + /** + * Get the first non-blank value from the given environment variable names. + * If no non-blank value is found, {@code null} is returned. + */ + public static final String env(String... names) { + if ( names==null || names.length==0 ) { + throw new FcliBugException("At least one environment variable name must be specified"); + } + for (String name : names) { + var value = env(name); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + /** + * Similar to {@link #env(String...)}, but allows for multiple arrays of environment + * variable names to be specified. + */ + public static final String env(String[]... namesArrays) { + for (String[] names : namesArrays) { + var value = env(names); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + public static String envSystemPropertyName(String envName) { return "fcli.env."+envName; } diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ado-pr-comment.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ado-pr-comment.yaml new file mode 100644 index 00000000000..285f2ebe714 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ado-pr-comment.yaml @@ -0,0 +1,162 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +# For now, this template uses latest release state to generate PR comments. + +author: Fortify +usage: + header: (PREVIEW) Add Azure DevOps Pull Request comment. + description: | + This action adds a comment to an Azure DevOps Pull Request. Currently + this is marked as PREVIEW as we build out this functionality; later + versions may have different behavior and/or require different action + cli. + + For best results, this fcli action should only be run on Azure DevOps + pull request build triggers. Upon PR creation, a new FoD release should + be created, copying state from the FoD release that represents the + branch into which the PR will be merged, and a new scan should be + run on the current PR branch before invoking this fcli action. + + This will ensure that scan results for the current PR will be + compared against the latest scan results for the target branch + upon PR creation. Optionally, new scans can be run upon PR changes, + creating new PR comments that show the issue delta compared to the + previous scan for this PR. + + Authentication defaults to "Authorization: Bearer " using + ADO_TOKEN/SYSTEM_ACCESSTOKEN or --ado-token, which is suitable for + Azure DevOps pipeline access tokens. If you want to use a PAT with + Basic auth, provide the full Authorization header through + ADO_AUTHORIZATION or --ado-authorization (for example, + "Basic "). The organization URL, project, repository ID, + and pull request ID can be automatically populated from Azure DevOps CI + environment variables. + +config: + rest.target.default: fod + +cli.options: + release: + names: --release, --rel + description: "Required release id or :[:]. Default value FOD_RELEASE environment variable." + required: true + default: ${#env('FOD_RELEASE')} + scan-type: + names: --scan-type, -t + description: "Scan type for which to list vulnerabilities. Default value: Static" + required: true + default: Static + ado-organization-url: + names: --ado-organization-url + description: "Azure DevOps organization URL. Auto-detected when running on Azure DevOps; provide for local testing." + required: true + default: "${#ado.env?.organization}" + ado-token: + names: --ado-token + description: "Azure DevOps token used with Bearer auth by default. Auto-detected when running on Azure DevOps; provide for local testing." + required: false + default: "${#ado.env?.token}" + ado-authorization: + names: --ado-authorization + description: "Optional full Authorization header value (for example, Basic ), overriding `--ado-token`." + required: false + project: + names: --project + description: "Azure DevOps project name. Auto-detected when running on Azure DevOps; provide for local testing." + required: false + default: "${#ado.env?.project}" + repository-id: + names: --repository-id + description: "Repository ID or name. Auto-detected when running on Azure DevOps; provide for local testing." + required: true + default: "${#ado.env?.repositoryId}" + pr-id: + names: --pr-id + description: "Pull request ID. Auto-detected when running on Azure DevOps; provide for local testing." + required: true + default: "${#ado.env?.pullRequest?.id}" + dryrun: + names: --dryrun + description: "Set to true to output request body without posting comment." + type: boolean + required: false + default: false + +steps: + - var.set: + adoEnv: ${#ado.env} + + - rest.target: + ado: + baseUrl: "${adoEnv!=null?adoEnv.organization:cli['ado-organization-url']}" + headers: + Authorization: "${#ifBlank(cli['ado-authorization'],'Bearer '+cli['ado-token'])}" + 'Content-Type': 'application/json' + - var.set: + rel: ${#fod.release(cli.release)} + - log.progress: Processing issue data + - rest.call: + issues: + uri: /api/v3/releases/${rel.releaseId}/vulnerabilities?limit=50 + query: + includeFixed: true + filters: scantype:${cli['scan-type']} + log.progress: + page.post-process: Processed ${totalIssueCount?:0} of ${issues_raw.totalCount} issues + records.for-each: + record.var-name: issue + if: ${issue.status!='Existing'} + do: + - var.set: + removedIssues..: {fmt: mdIssueListItem, if: "${issue.status=='Fix Validated'}"} + newIssues..: {fmt: mdIssueListItem, if: "${(issue.status=='New' || issue.status=='Reopen')}"} + + - var.set: + commentBody: {fmt: commentBody} + threadRequestBody: {fmt: threadRequestBody} + hasIssueStatusChanges: ${newIssues!=null || removedIssues!=null} + adoProjectPath: ${(adoEnv!=null?adoEnv.project:cli['project']).replace(' ','%20')} + + - if: ${cli.dryrun && hasIssueStatusChanges} + log.info: {msg: "${commentBody}"} + + - if: ${cli.dryrun && !hasIssueStatusChanges} + log.info: {msg: "No issue status changes detected; skipping PR thread."} + + - if: ${!cli.dryrun && hasIssueStatusChanges} + rest.call: + postPRThread: + method: POST + uri: "/${adoProjectPath}/_apis/git/repositories/${adoEnv!=null?adoEnv.repositoryId:cli['repository-id']}/pullRequests/${adoEnv!=null?adoEnv.pullRequest.id:cli['pr-id']}/threads" + target: ado + query: + 'api-version': '7.0' + body: ${threadRequestBody} + + - if: ${!cli.dryrun && !hasIssueStatusChanges} + log.info: {msg: "No issue status changes detected; not posting PR comment."} + +formatters: + threadRequestBody: + comments: + - content: ${commentBody} + + commentBody: | + ## Fortify vulnerability summary + + Any issues listed below are based on comparing the latest scan results against the previous + scan results in FoD release [${rel.applicationName}${#isNotBlank(rel.microserviceName)?' - '+rel.microserviceName:''} - ${rel.releaseName}](${#fod.releaseBrowserUrl(rel)}). + + ### New Issues + + ${newIssues==null + ? "* No new or re-introduced issues were detected" + : ("* "+#join('\n* ',newIssues))} + + ### Removed Issues + + ${removedIssues==null + ? "* No removed issues were detected" + : ("* "+#join('\n* ',removedIssues))} + + mdIssueListItem: "${issue.status} (${issue.scantype}) - ${issue.category}: \n[${issue.primaryLocationFull}${issue.lineNumber==null?'':':'+issue.lineNumber}](${#fod.issueBrowserUrl(issue)})" diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml index 3a48f95349c..e02feb6f9b5 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/ci.yaml @@ -135,6 +135,15 @@ steps: on.success: - var.set: { postScan.skipReason: } # Reset postScan.skipReason to allow post-scan tasks to run + AVIATOR_REMEDIATIONS: + cmd: ${#actionCmd('AVIATOR_REMEDIATIONS', 'fod', global.ci.fod_aviatorRemediationsAction)} "--rel=${global.ci.rel}" "--source-dir=${global.ci.sourceDir}" "--progress=none" + skip.if-reason: + - ${#actionCmdSkipFromEnvReason('AVIATOR_REMEDIATIONS', true)} # Skip unless DO_AVIATOR_REMEDIATIONS==true or AVIATOR_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${#actionCmdSkipNoActionReason('AVIATOR_REMEDIATIONS', 'fod', global.ci.fod_aviatorRemediationsAction)} + - ${postScan.skipReason} # Skip if no scans were run + - ${#env('DO_AVIATOR_AUDIT')!='true'?'Aviator audit not enabled (DO_AVIATOR_AUDIT!=true), no remediations available':''} # Skip if Aviator audit was not enabled + - ${SAST_WAIT.dependencySkipReason} # Skip if SAST scan/wait was skipped or failed + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'fod', 'check-policy')} "--rel=${global.ci.rel}" "--progress=none" stdout: collect diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-pr-comment.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-pr-comment.yaml index 3ca04836f20..5cc4154a7aa 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-pr-comment.yaml +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-pr-comment.yaml @@ -45,40 +45,40 @@ cli.options: default: Static github-api-url: names: --github-api-url - description: 'Required GitHub API URL. Default value: GITHUB_API_URL environment variable.' + description: 'Required GitHub API URL. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_API_URL')} + default: "${#github.env?.apiUrl}" mask: description: GITHUB HOST NAME pattern: https?://([^/]+).* sensitivity: low github-token: names: --github-token - description: 'Required GitHub Token. Default value: GITHUB_TOKEN environment variable.' + description: 'Required GitHub token. Auto-detected from GITHUB_TOKEN when available in the environment.' required: true - default: ${#env('GITHUB_TOKEN')} + default: "${#github.env?.token}" mask: sensitivity: high github-owner: names: --github-owner - description: 'Required GitHub repository owner. Default value: GITHUB_REPOSITORY_OWNER environment variable.' + description: 'Required GitHub repository owner. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_REPOSITORY_OWNER')} + default: "${#github.env?.repositoryOwner}" github-repo: names: --github-repo - description: 'Required GitHub repository. Default value: Taken from GITHUB_REPOSITORY environment variable.' + description: 'Required GitHub repository. Auto-detected when running on GitHub Actions.' required: true - default: ${#substringAfter(#env('GITHUB_REPOSITORY'),'/')} + default: "${#github.env?.repository}" pr: names: --pr - description: "Required PR number. Default value: Taken from GITHUB_REF_NAME environment variable. Note that default value will only work on GitHub pull_request triggers; if this fcli action is invoked through any other GitHub trigger, it will fail unless an explicit PR number is passed through this option." + description: "Required PR number. Auto-detected when running on GitHub pull_request triggers; if this action is invoked through any other GitHub trigger, pass an explicit PR number through this option." required: true - default: ${#substringBefore(#env('GITHUB_REF_NAME'),'/')} + default: "${#github.env?.pullRequest?.id}" commit: names: --commit - description: 'Required commit hash. Default value: GITHUB_SHA environment variable.' + description: 'Required commit hash. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_SHA')} + default: "${#github.env?.ciCommit?.headId?.full}" dryrun: names: --dryrun description: "Set to true to just output PR decoration JSON; don't actually update any PR" diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml new file mode 100644 index 00000000000..8555287bf65 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/github-remediations-pr.yaml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and raise a GitHub pull request + description: | + This action runs the `push-remediations` action to apply Aviator-generated remediations + and push them to a new branch, then creates a GitHub pull request for that branch. If the + remediation step produced no changes, no branch is pushed and no pull request is created. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--rel` -- FoD release to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--base-branch` / `BASE_BRANCH` -- Target branch for the pull request (default: current branch) + - `--title` / `PR_TITLE` -- Pull request title + - `--body` / `PR_BODY` -- Pull request description + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + rel: + names: --rel + description: "FoD release to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + baseBranch: + names: --base-branch + description: "Target branch for the pull request. Defaults to the currently checked-out branch, falling back to the repository default branch or 'main'." + required: false + default: "${#env('BASE_BRANCH')?:''}" + title: + names: --title, -t + description: "Pull request title." + required: false + default: "${#env('PR_TITLE')?:'fix: Fortify auto-remediation fixes [Generated by fcli aviator]'}" + body: + names: --body + description: "Pull request description." + required: false + default: "${#env('PR_BODY')?:'This pull request contains changes applied by fcli aviator.'}" + +steps: + # Capture the currently checked-out branch to use as the pull request base, before + # push-remediations checks out the new remediation branch. + - var.set: + baseBranch: "${#ifBlank(cli.baseBranch, #ifBlank(#git.localRepo(cli.sourceDir)?.branch?.short, #ifBlank(#git.defaultBranch(cli.sourceDir), 'main')))}" + + # Apply remediations and push them to the new branch + - run.fcli: + PUSH_REMEDIATIONS: + cmd: fod action run push-remediations "--source-dir=${cli.sourceDir}" "--rel=${cli.rel}" "--branch-name=${cli.branchName}" "--progress=none" + status.check: true + + # Only create a pull request if push-remediations actually created and checked out the + # remediation branch (i.e. there were remediation changes to push). + - var.set: + currentBranch: ${#git.localRepo(cli.sourceDir)?.branch?.short} + - if: ${currentBranch!=cli.branchName} + do: + - log.info: "No remediation changes were pushed; skipping pull request creation." + - exit: 0 + + # Create the GitHub pull request + - var.set: + pr: ${#github.repo().createPullRequest(cli.title, cli.branchName, baseBranch, cli.body)} + on.fail: + - throw: + msg: "Failed to create GitHub pull request from '${cli.branchName}' to '${baseBranch}'" + cause: ${lastException} + - if: "${#isBlank(pr) || #isBlank(pr.html_url)}" + throw: "GitHub pull request creation did not return the expected response." + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.prUrl: ${pr.html_url} + - log.info: "Created GitHub pull request #${pr.number}: ${pr.html_url}" diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/gitlab-mr-comment.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/gitlab-mr-comment.yaml new file mode 100644 index 00000000000..eb6fa1dc0c8 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/gitlab-mr-comment.yaml @@ -0,0 +1,145 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +# For now, this template uses latest release state to generate MR comments. + +author: Fortify +usage: + header: (PREVIEW) Add GitLab Merge Request comment. + description: | + This action adds review comments to a GitLab Merge Request. Currently + this is marked as PREVIEW as we build out this functionality; later + versions may have different behavior and/or require different action + cli. + + For best results, this fcli action should only be run on GitLab + merge_request_event. Upon MR creation, a new FoD release should + be created, copying state from the FoD release that represents the + branch into which the MR will be merged, and a new scan should be + run on the current MR branch before invoking this fcli action. + + This will ensure that scan results for the current MR will be + compared against the latest scan results for the target branch + upon MR creation. Optionally, new scans can be run upon MR changes, + creating new MR comments that show the issue delta compared to the + previous scan for this MR. + + You will need to provide a GitLab token or set one of the environment + variables "GITLAB_TOKEN" (recommended) or "GITLAB_API_TOKEN" with + permissions to post comments on the MR, and the GitLab API v4 base URL + (e.g., https://gitlab.com/api/v4) if not using the default gitlab.com. + The project id and MR IID must also be provided, but can be automatically + populated from CI environment variables if running in GitLab CI. + +config: + rest.target.default: fod + +cli.options: + release: + names: --release, --rel + description: "Required release id or :[:]. Default value FOD_RELEASE environment variable." + required: true + default: ${#env('FOD_RELEASE')} + scan-type: + names: --scan-type, -t + description: "Scan type for which to list vulnerabilities. Default value Static." + required: true + default: Static + gitlab-api-url: + names: --gitlab-api-url + description: "Required GitLab API v4 base URL (e.g., https://gitlab.com/api/v4 or https://gitlab.example.com/api/v4). Auto-detected when running on GitLab CI." + required: true + default: "${#gitlab.env?.apiV4Url}" + gitlab-token: + names: --gitlab-token + description: "Required GitLab token. Auto-detected when running on GitLab CI." + required: true + default: "${#gitlab.env?.token}" + mask: + sensitivity: high + project-id: + names: --project-id + description: "Required GitLab project id. Auto-detected when running on GitLab CI." + required: true + default: "${#gitlab.env?.projectId}" + mr-iid: + names: --mr-iid + description: "Required Merge Request IID. Auto-detected when running on GitLab CI merge request pipelines." + required: true + default: "${#gitlab.env?.pullRequest?.id}" + dryrun: + names: --dryrun + description: "Set to true to output request body without posting comment." + type: boolean + required: false + default: false + +steps: + - rest.target: + gitlab: + baseUrl: ${cli['gitlab-api-url']} + headers: + PRIVATE-TOKEN: ${cli['gitlab-token']} + - var.set: + rel: ${#fod.release(cli.release)} + - log.progress: Processing issue data + - rest.call: + issues: + uri: /api/v3/releases/${rel.releaseId}/vulnerabilities?limit=50 + query: + includeFixed: true + filters: scantype:${cli['scan-type']} + log.progress: + page.post-process: Processed ${totalIssueCount?:0} of ${issues_raw.totalCount} issues + records.for-each: + record.var-name: issue + if: ${issue.status!='Existing'} + do: + - var.set: + removedIssues..: {fmt: mdIssueListItem, if: "${issue.status=='Fix Validated'}"} + newIssues..: {fmt: mdIssueListItem, if: "${(issue.status=='New' || issue.status=='Reopen')}"} + + - var.set: + reviewBody: {fmt: reviewBody} + noteRequestBody: {fmt: noteRequestBody} + hasIssueStatusChanges: ${newIssues!=null || removedIssues!=null} + + - if: ${cli.dryrun && hasIssueStatusChanges} + log.info: {msg: "${noteRequestBody}"} + + - if: ${cli.dryrun && !hasIssueStatusChanges} + log.info: {msg: "No issue status changes detected; skipping MR note."} + + - if: ${!cli.dryrun && hasIssueStatusChanges} + rest.call: + postMRNote: + method: POST + uri: /projects/${cli['project-id']}/merge_requests/${cli['mr-iid']}/notes + target: gitlab + body: ${noteRequestBody} + + - if: ${!cli.dryrun && !hasIssueStatusChanges} + log.info: {msg: "No issue status changes detected; not posting MR comment."} + +formatters: + noteRequestBody: + body: ${reviewBody} + + reviewBody: | + ## Fortify vulnerability summary + + Any issues listed below are based on comparing the latest scan results against the previous + scan results in FoD release [${rel.applicationName}${#isNotBlank(rel.microserviceName)?' - '+rel.microserviceName:''} - ${rel.releaseName}](${#fod.releaseBrowserUrl(rel)}). + + ### New Issues + + ${newIssues==null + ? "* No new or re-introduced issues were detected" + : ("* "+#join('\n* ',newIssues))} + + ### Removed Issues + + ${removedIssues==null + ? "* No removed issues were detected" + : ("* "+#join('\n* ',removedIssues))} + + mdIssueListItem: "${issue.status} (${issue.scantype}) - ${issue.category}: \n[${issue.primaryLocationFull}${issue.lineNumber==null?'':':'+issue.lineNumber}](${#fod.issueBrowserUrl(issue)})" diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml new file mode 100644 index 00000000000..a5b75b336c1 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/actions/zip/push-remediations.yaml @@ -0,0 +1,103 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and push them to a new branch + description: | + This action applies Aviator-generated remediations to the local source code, then + commits and pushes only those changes to a new branch on the remote repository. + + Change detection is snapshot-based: any files that were already modified before the + remediations were applied (for example build output) are left untouched; only the + files changed by the remediation step are committed. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--rel` -- FoD release to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--commit-message` / `COMMIT_MESSAGE` + - `--author-name` / `GIT_AUTHOR_NAME` + - `--author-email` / `GIT_AUTHOR_EMAIL` + + Authentication for the push uses the `GIT_PUSH_TOKEN` environment variable if set, + falling back to the token of the active CI system, and finally to the local git + configuration (for example an http.extraHeader injected by the CI checkout step). + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + rel: + names: --rel + description: "FoD release to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + commitMessage: + names: --commit-message, -m + description: "Git commit message." + required: false + default: "${#env('COMMIT_MESSAGE')?:'fix: apply automated fixes [generated by fcli aviator]'}" + authorName: + names: --author-name + description: "Git author name for the commit." + required: false + default: "${#env('GIT_AUTHOR_NAME')?:'fcli-aviator[bot]'}" + authorEmail: + names: --author-email + description: "Git author email for the commit." + required: false + default: "${#env('GIT_AUTHOR_EMAIL')?:'fcli-aviator@opentext.com'}" + +steps: + # Validate that the source directory is a git working tree with a configured remote + - var.set: + gitRepoInfo: ${#git.localRepo(cli.sourceDir)} + - if: ${#isBlank(gitRepoInfo)} + throw: "Source directory '${cli.sourceDir}' is not a git repository." + - if: ${#isBlank(gitRepoInfo.repository.remoteUrl)} + throw: "Git repository has no remote URL configured; a remote is required to push remediations." + + # Snapshot the paths that are already dirty before remediations are applied, so that + # pre-existing changes (e.g. build output) are excluded from the remediation commit. + - var.set: + snapshot: ${#git.status(cli.sourceDir)} + + # Apply the Aviator remediations to the local source code + - run.fcli: + APPLY_REMEDIATIONS: + cmd: fod aviator apply-remediations "--rel=${cli.rel}" "--source-dir=${cli.sourceDir}" "--progress=none" + status.check: true + + # Commit only the changes introduced by the remediation step to a new branch + - var.set: + commitSha: ${#git.commitChangesSince(cli.sourceDir, snapshot, cli.branchName, cli.commitMessage, cli.authorName, cli.authorEmail)} + on.fail: + - throw: + msg: "Failed to commit Aviator remediations" + cause: ${lastException} + - if: ${#isBlank(commitSha)} + do: + - log.info: "No remediation changes to commit; nothing was pushed." + - exit: 0 + - log.info: "Committed Aviator remediations to branch '${cli.branchName}' (${commitSha})" + + # Push the new branch to the remote repository + - var.set: + pushedRef: ${#git.push(cli.sourceDir, cli.branchName)} + on.fail: + - throw: + msg: "Failed to push branch '${cli.branchName}'" + cause: ${lastException} + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.pushed: true + - log.info: "Pushed Aviator remediations to branch '${cli.branchName}' (${pushedRef})" diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml index f6aa05d32ba..a6d989daf4b 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/ci.yaml @@ -195,15 +195,27 @@ steps: skip.if-reason: - ${postScan.skipReason} # Skip if no scans were run - ${aviator.skipReason} # Skip if Aviator audit is to be skipped - - + on.success: + - var.set: + aviator.artifactId: "${#var('aviator_audit').hasNonNull('artifactId') ? #var('aviator_audit').get('artifactId').asText() : ''}" + AVIATOR_WAIT: - cmd: "${#fcliCmd('AVIATOR_WAIT', 'ssc artifact wait-for')} ::aviator_audit::" + cmd: "${#fcliCmd('AVIATOR_WAIT', 'ssc artifact wait-for')} ${aviator.artifactId}" skip.if-reason: - ${postScan.skipReason} # Skip if no scans were run - ${aviator.skipReason} # Skip if Aviator audit is to be skipped - ${AVIATOR_AUDIT.dependencySkipReason} # Skip if AVIATOR_AUDIT was skipped or failed + - "${#isBlank(aviator.artifactId) ? 'No artifact produced by Aviator audit, nothing to wait for' : ''}" + AVIATOR_REMEDIATIONS: + cmd: ${#actionCmd('AVIATOR_REMEDIATIONS', 'ssc', global.ci.ssc_aviatorRemediationsAction)} "--source-dir=${global.ci.sourceDir}" "--artifact-id=${aviator.artifactId}" "--progress=none" + skip.if-reason: + - ${#actionCmdSkipFromEnvReason('AVIATOR_REMEDIATIONS', true)} # Skip unless DO_AVIATOR_REMEDIATIONS==true or AVIATOR_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${#actionCmdSkipNoActionReason('AVIATOR_REMEDIATIONS', 'ssc', global.ci.ssc_aviatorRemediationsAction)} + - ${postScan.skipReason} # Skip if no scans were run + - ${aviator.skipReason} # Skip if Aviator is not configured + - ${AVIATOR_WAIT.dependencySkipReason} # Skip if AVIATOR_WAIT was skipped or failed (no remediations available) + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'ssc', 'check-policy')} "--av=${global.ci.av}" "--progress=none" stdout: collect @@ -283,4 +295,3 @@ formatters: ${CHECK_POLICY.exitCode==0||CHECK_POLICY.exitCode==100?CHECK_POLICY.stdout:CHECK_POLICY.dependencySkipReason} ${APPVERSION_SUMMARY.stdout} - \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml index ee5fd339896..a096fe4cb16 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/debricked-scan.yaml @@ -79,7 +79,7 @@ steps: - if: ${#isBlank(cli.repository)||#isBlank(cli.branch)} do: - var.set: - localRepository: ${#localRepo(cli.sourceDir)} + localRepository: ${#git.localRepo(cli.sourceDir)} - var.set: repo: ${#ifBlank(cli.repository, localRepository?.repository?.name?.full)} branch: ${#ifBlank(cli.branch, localRepository?.branch?.short)} diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-pr-comment.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-pr-comment.yaml index 862dc90d89a..ffadeeb67a5 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-pr-comment.yaml +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-pr-comment.yaml @@ -47,40 +47,40 @@ cli.options: default: SCA github-api-url: names: --github-api-url - description: 'Required GitHub API URL. Default value: GITHUB_API_URL environment variable.' + description: 'Required GitHub API URL. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_API_URL')} + default: "${#github.env?.apiUrl}" mask: description: GITHUB HOST NAME pattern: https?://([^/]+).* sensitivity: low github-token: names: --github-token - description: 'Required GitHub Token. Default value: GITHUB_TOKEN environment variable.' + description: 'Required GitHub token. Auto-detected from GITHUB_TOKEN when available in the environment.' required: true - default: ${#env('GITHUB_TOKEN')} + default: "${#github.env?.token}" mask: sensitivity: high github-owner: names: --github-owner - description: 'Required GitHub repository owner. Default value: GITHUB_REPOSITORY_OWNER environment variable.' + description: 'Required GitHub repository owner. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_REPOSITORY_OWNER')} + default: "${#github.env?.repositoryOwner}" github-repo: names: --github-repo - description: 'Required GitHub repository. Default value: Taken from GITHUB_REPOSITORY environment variable.' + description: 'Required GitHub repository. Auto-detected when running on GitHub Actions.' required: true - default: ${#substringAfter(#env('GITHUB_REPOSITORY'),'/')} + default: "${#github.env?.repository}" pr: names: --pr - description: "Required PR number. Default value: Taken from GITHUB_REF_NAME environment variable. Note that default value will only work on GitHub pull_request triggers; if this fcli action is invoked through any other GitHub trigger, it will fail unless an explicit PR number is passed through this option." + description: "Required PR number. Auto-detected when running on GitHub pull_request triggers; if this action is invoked through any other GitHub trigger, pass an explicit PR number through this option." required: true - default: ${#substringBefore(#env('GITHUB_REF_NAME'),'/')} + default: "${#github.env?.pullRequest?.id}" commit: names: --commit - description: 'Required commit hash. Default value: GITHUB_SHA environment variable.' + description: 'Required commit hash. Auto-detected when running on GitHub Actions.' required: true - default: ${#env('GITHUB_SHA')} + default: "${#github.env?.ciCommit?.headId?.full}" dryrun: names: --dryrun description: "Set to true to just output PR decoration JSON; don't actually update any PR" diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml new file mode 100644 index 00000000000..34359123947 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/github-remediations-pr.yaml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and raise a GitHub pull request + description: | + This action runs the `push-remediations` action to apply Aviator-generated remediations + and push them to a new branch, then creates a GitHub pull request for that branch. If the + remediation step produced no changes, no branch is pushed and no pull request is created. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--artifact-id` -- SSC artifact id to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--base-branch` / `BASE_BRANCH` -- Target branch for the pull request (default: current branch) + - `--title` / `PR_TITLE` -- Pull request title + - `--body` / `PR_BODY` -- Pull request description + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + artifactId: + names: --artifact-id + description: "SSC artifact id to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + baseBranch: + names: --base-branch + description: "Target branch for the pull request. Defaults to the currently checked-out branch, falling back to the repository default branch or 'main'." + required: false + default: "${#env('BASE_BRANCH')?:''}" + title: + names: --title, -t + description: "Pull request title." + required: false + default: "${#env('PR_TITLE')?:'fix: Fortify auto-remediation fixes [Generated by fcli aviator]'}" + body: + names: --body + description: "Pull request description." + required: false + default: "${#env('PR_BODY')?:'This pull request contains changes applied by fcli aviator.'}" + +steps: + # Capture the currently checked-out branch to use as the pull request base, before + # push-remediations checks out the new remediation branch. + - var.set: + baseBranch: "${#ifBlank(cli.baseBranch, #ifBlank(#git.localRepo(cli.sourceDir)?.branch?.short, #ifBlank(#git.defaultBranch(cli.sourceDir), 'main')))}" + + # Apply remediations and push them to the new branch + - run.fcli: + PUSH_REMEDIATIONS: + cmd: ssc action run push-remediations "--source-dir=${cli.sourceDir}" "--artifact-id=${cli.artifactId}" "--branch-name=${cli.branchName}" "--progress=none" + status.check: true + + # Only create a pull request if push-remediations actually created and checked out the + # remediation branch (i.e. there were remediation changes to push). + - var.set: + currentBranch: ${#git.localRepo(cli.sourceDir)?.branch?.short} + - if: ${currentBranch!=cli.branchName} + do: + - log.info: "No remediation changes were pushed; skipping pull request creation." + - exit: 0 + + # Create the GitHub pull request + - var.set: + pr: ${#github.repo().createPullRequest(cli.title, cli.branchName, baseBranch, cli.body)} + on.fail: + - throw: + msg: "Failed to create GitHub pull request from '${cli.branchName}' to '${baseBranch}'" + cause: ${lastException} + - if: "${#isBlank(pr) || #isBlank(pr.html_url)}" + throw: "GitHub pull request creation did not return the expected response." + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.prUrl: ${pr.html_url} + - log.info: "Created GitHub pull request #${pr.number}: ${pr.html_url}" diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml new file mode 100644 index 00000000000..21780226faf --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/push-remediations.yaml @@ -0,0 +1,103 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Apply Aviator remediations and push them to a new branch + description: | + This action applies Aviator-generated remediations to the local source code, then + commits and pushes only those changes to a new branch on the remote repository. + + Change detection is snapshot-based: any files that were already modified before the + remediations were applied (for example build output) are left untouched; only the + files changed by the remediation step are committed. + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Source directory to apply remediations to (default: '.') + - `--artifact-id` -- SSC artifact id to retrieve remediations for + - `--branch-name` / `BRANCH_NAME` -- Full branch name to create and push + - `--commit-message` / `COMMIT_MESSAGE` + - `--author-name` / `GIT_AUTHOR_NAME` + - `--author-email` / `GIT_AUTHOR_EMAIL` + + Authentication for the push uses the `GIT_PUSH_TOKEN` environment variable if set, + falling back to the token of the active CI system, and finally to the local git + configuration (for example an http.extraHeader injected by the CI checkout step). + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: "Source directory to apply remediations to. Defaults to current working directory." + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + artifactId: + names: --artifact-id + description: "SSC artifact id to retrieve remediations for." + required: true + branchName: + names: --branch-name + description: "Full branch name to create and push." + required: false + default: "${#env('BRANCH_NAME')?:'aviator/remediations/'+#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + commitMessage: + names: --commit-message, -m + description: "Git commit message." + required: false + default: "${#env('COMMIT_MESSAGE')?:'fix: apply automated fixes [generated by fcli aviator]'}" + authorName: + names: --author-name + description: "Git author name for the commit." + required: false + default: "${#env('GIT_AUTHOR_NAME')?:'fcli-aviator[bot]'}" + authorEmail: + names: --author-email + description: "Git author email for the commit." + required: false + default: "${#env('GIT_AUTHOR_EMAIL')?:'fcli-aviator@opentext.com'}" + +steps: + # Validate that the source directory is a git working tree with a configured remote + - var.set: + gitRepoInfo: ${#git.localRepo(cli.sourceDir)} + - if: ${#isBlank(gitRepoInfo)} + throw: "Source directory '${cli.sourceDir}' is not a git repository." + - if: ${#isBlank(gitRepoInfo.repository.remoteUrl)} + throw: "Git repository has no remote URL configured; a remote is required to push remediations." + + # Snapshot the paths that are already dirty before remediations are applied, so that + # pre-existing changes (e.g. build output) are excluded from the remediation commit. + - var.set: + snapshot: ${#git.status(cli.sourceDir)} + + # Apply the Aviator remediations to the local source code + - run.fcli: + APPLY_REMEDIATIONS: + cmd: aviator ssc apply-remediations "--source-dir=${cli.sourceDir}" "--artifact-id=${cli.artifactId}" "--progress=none" + status.check: true + + # Commit only the changes introduced by the remediation step to a new branch + - var.set: + commitSha: ${#git.commitChangesSince(cli.sourceDir, snapshot, cli.branchName, cli.commitMessage, cli.authorName, cli.authorEmail)} + on.fail: + - throw: + msg: "Failed to commit Aviator remediations" + cause: ${lastException} + - if: ${#isBlank(commitSha)} + do: + - log.info: "No remediation changes to commit; nothing was pushed." + - exit: 0 + - log.info: "Committed Aviator remediations to branch '${cli.branchName}' (${commitSha})" + + # Push the new branch to the remote repository + - var.set: + pushedRef: ${#git.push(cli.sourceDir, cli.branchName)} + on.fail: + - throw: + msg: "Failed to push branch '${cli.branchName}'" + cause: ${lastException} + - var.set: + global.aviatorRemediations.branch: ${cli.branchName} + global.aviatorRemediations.pushed: true + - log.info: "Pushed Aviator remediations to branch '${cli.branchName}' (${pushedRef})" diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index b3f97b9cfc2..2b07dc07724 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -48,8 +48,8 @@ fcli.util.crypto.decrypt.usage.header = Decrypt a value. fcli.util.crypto.decrypt.prompt = Value to decrypt: # fcli util mcp-server -fcli.util.mcp-server.usage.header = (PREVIEW, DEPRECATED) Legacy MCP server compatibility commands -fcli.util.mcp-server.start.usage.header = (PREVIEW, DEPRECATED) Start legacy fcli MCP server command +fcli.util.mcp-server.usage.header = (DEPRECATED) Legacy MCP server compatibility commands +fcli.util.mcp-server.start.usage.header = (DEPRECATED) Start legacy fcli MCP server command fcli.util.mcp-server.start.usage.description = This command is deprecated and kept only for backward compatibility. Use 'fcli agent mcp start-stdio' instead. All given arguments are forwarded to the new command. fcli.util.mcp-server.start.module = Fcli module to expose through this MCP server instance. fcli.util.mcp-server.start.import = Action YAML files to import. Exported functions are registered as MCP tools or resources based on function metadata. diff --git a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy index 8da4460fddd..1984b402cd0 100644 --- a/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy +++ b/fcli-other/fcli-functional-test/src/ftest/groovy/com/fortify/cli/ftest/core/DetectEnvSpec.groovy @@ -36,6 +36,9 @@ class DetectEnvSpec extends FcliBaseSpec { // Verify non-PR properties it.any { it.contains("prActive: false") } it.any { it.contains("prNotActiveSkipReason: Not a Pull Request") } + it.any { it.contains("fod_prCommentAction: github-pr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: github-remediations-pr") } + it.any { it.contains("ssc_aviatorRemediationsAction: github-remediations-pr") } } } @@ -64,6 +67,9 @@ class DetectEnvSpec extends FcliBaseSpec { // Verify non-PR properties (uses "Merge Request" terminology for GitLab) it.any { it.contains("prActive: false") } it.any { it.contains("prNotActiveSkipReason: Not a Merge Request") } + it.any { it.contains("fod_prCommentAction: gitlab-mr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: push-remediations") } + it.any { it.contains("ssc_aviatorRemediationsAction: push-remediations") } } } @@ -150,6 +156,9 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prId: 123") } it.any { it.contains("prTarget: main") } it.any { it.contains("prTerminology: Pull Request") } + it.any { it.contains("fod_prCommentAction: github-pr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: github-remediations-pr") } + it.any { it.contains("ssc_aviatorRemediationsAction: github-remediations-pr") } } } @@ -177,6 +186,9 @@ class DetectEnvSpec extends FcliBaseSpec { it.any { it.contains("prId: 42") } it.any { it.contains("prTarget: main") } it.any { it.contains("prTerminology: Merge Request") } + it.any { it.contains("fod_prCommentAction: gitlab-mr-comment") } + it.any { it.contains("fod_aviatorRemediationsAction: push-remediations") } + it.any { it.contains("ssc_aviatorRemediationsAction: push-remediations") } } } diff --git a/gradle.properties b/gradle.properties index 9f171c3b906..fa61b8480f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -51,7 +51,7 @@ fcliMainClassName=com.fortify.cli.app.FortifyCLI # given schema version, it is very important to maintain this correctly. At all cost, # we should avoid for example updating only patch version if there are any structural # changes. -fcliActionSchemaVersion=2.8.0 +fcliActionSchemaVersion=2.9.0 org.gradle.parallel=true # Ensure JDK IO subsystem is opened for all Gradle daemon JVM processes (suppresses native subprocess control warning)