diff --git a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/create-pr.yaml b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/create-pr.yaml new file mode 100644 index 00000000000..5455cef49d1 --- /dev/null +++ b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/create-pr.yaml @@ -0,0 +1,127 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json +author: Fortify +usage: + header: Create a pull request or merge request on GitHub or GitLab + description: | + This action creates a pull request (GitHub) or merge request (GitLab) for an + existing branch. The repository platform is auto-detected from the git remote URL. + Only GitHub and GitLab are supported; for other platforms the branch must be + promoted to a PR/MR manually. + + Typically used after `git-stage-commit-push` to raise a PR/MR for the pushed branch. + + This action requires: + - Git repository with a remote pointing to GitHub or GitLab + - A CI token with PR/MR creation permissions (GITHUB_TOKEN/GH_TOKEN or CI_JOB_TOKEN) + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Local repo directory used to detect the platform (default: '.') + - `--source-branch` / `SOURCE_BRANCH` -- Branch to raise the PR/MR from (required) + - `--base-branch` / `BASE_BRANCH` -- Target branch for the PR/MR (default: auto-detected from remote, fallback "main") + - `--title` / `PR_TITLE` -- PR/MR title + - `--body` / `PR_BODY` -- PR/MR description + +config: + output: immediate + +cli.options: + sourceDir: + names: --source-dir, -s + description: >- + Local repo directory used to detect the repository platform. + Defaults to current working directory if not specified. + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + sourceBranch: + names: --source-branch + description: >- + Source branch to raise the PR/MR from. + required: true + default: "${#env('SOURCE_BRANCH')?:''}" + baseBranch: + names: --base-branch + description: >- + Target branch for the PR/MR. Auto-detected from the remote HEAD if not specified, + falling back to "main". + required: false + default: "${#env('BASE_BRANCH')?:''}" + title: + names: --title, -t + description: >- + PR/MR title. + required: false + default: "${#env('PR_TITLE')?:'fix: Fortify auto-remediation fixes [Generated by fcli aviator]'}" + body: + names: --body + description: >- + PR/MR description. + required: false + default: "${#env('PR_BODY')?:'This pull request contains changes applied by fcli aviator.'}" + +steps: + - if: "${#isBlank(cli.sourceBranch)}" + throw: "Option '--source-branch' is required. Pass the source branch from which the PR/MR is to be created." + + # Compute base branch: CLI option/env var -> git remote HEAD -> "main" + - var.set: + baseBranch: "${#ifBlank(cli.baseBranch, #ifBlank(#git.defaultBranch(cli.sourceDir), 'main'))}" + + # Detect repository platform from git remote URL + - var.set: + repoPlatform: ${#git.repositoryPlatform(cli.sourceDir)} + + # GitHub PR creation + - if: "${repoPlatform=='github'}" + do: + - var.set: + repoOwner: "${#git.ciRepositoryOwner()}" + prHead: "${repoOwner}:${cli.sourceBranch}" + - log.debug: "Creating GitHub PR: head=${prHead}, base=${baseBranch}, title=${cli.title}" + - var.set: + pr: ${#_ci.detect().repo().createPullRequest(cli.title, prHead, baseBranch, cli.body)} + on.fail: + - log.warn: + msg: "Failed to create GitHub Pull Request from '${cli.sourceBranch}' to '${baseBranch}'" + cause: ${lastException} + - log.warn: "Troubleshooting: Verify branch '${cli.sourceBranch}' exists on remote and base branch '${baseBranch}' is valid" + - log.debug: "Failed to create GitHub PR: ${lastException.message}" + - throw: + msg: "Failed to create GitHub Pull Request from '${cli.sourceBranch}' to '${baseBranch}'" + cause: ${lastException} + - if: ${#isNotBlank(pr)} && ${#isNotBlank(pr.html_url)} && ${#isNotBlank(pr.number)} + do: + - log.info: "Created GitHub Pull Request #${pr.number}: ${pr.html_url}" + + - if: ${#isBlank(pr)} || ${#isBlank(pr.html_url)} + do: + - log.warn: "PR creation returned empty or invalid response: ${pr}" + - throw: "GitHub PR creation did not return expected response. Check logs for details." + # GitLab MR creation + - if: "${repoPlatform=='gitlab'}" + do: + - log.debug: "Creating GitLab Merge Request: source=${cli.sourceBranch}, target=${baseBranch}, title=${cli.title}" + - var.set: + mr: ${#_ci.detect().project().createMergeRequest(cli.title, cli.sourceBranch, baseBranch, cli.body)} + on.fail: + - log.warn: + msg: "Failed to create GitLab Merge Request from '${cli.sourceBranch}' to '${baseBranch}'" + cause: ${lastException} + - log.warn: "Troubleshooting: Verify branch '${cli.sourceBranch}' exists on remote and base branch '${baseBranch}' is valid" + - throw: + msg: "Failed to create GitLab Merge Request from '${cli.sourceBranch}' to '${baseBranch}'" + cause: ${lastException} + - if: ${#isNotBlank(mr)} && ${#isNotBlank(mr.web_url)} && ${#isNotBlank(mr.iid)} + do: + - log.info: "Created GitLab Merge Request !${mr.iid}: ${mr.web_url}" + + - if: ${#isBlank(mr)} || ${#isBlank(mr.web_url)} + do: + - log.warn: "Merge Request creation returned empty or invalid response: ${mr}" + - throw: "GitLab MR creation did not return expected response. Check logs for details." + + # Unsupported repository platform + - if: "${repoPlatform!='github' && repoPlatform!='gitlab'}" + do: + - log.warn: "PR/MR creation is not supported for repository platform '${repoPlatform}'. Branch '${cli.sourceBranch}' has been pushed; please create a PR/MR manually." + - throw: "PR/MR creation is not supported for repository platform '${repoPlatform}'" + - log.info: "create-pr completed: PR/MR created for branch '${cli.sourceBranch}' targeting '${baseBranch}'" diff --git a/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/git-push-changes.yaml b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/git-push-changes.yaml new file mode 100644 index 00000000000..282e1819b27 --- /dev/null +++ b/fcli-core/fcli-action/src/main/resources/com/fortify/cli/generic_action/actions/zip/git-push-changes.yaml @@ -0,0 +1,128 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json +author: Fortify +usage: + header: Stage, commit and push local changes to a new branch + description: | + This action detects local file changes, creates a new branch, stages and commits + the changes, then pushes the branch to the remote repository. Credentials are + auto-detected from the CI environment (GitHub Actions, GitLab CI, Azure DevOps, + Bitbucket Pipelines). + + This action requires: + - Git repository access (local clone with a configured remote) + - A CI token with push permissions (GITHUB_TOKEN, GH_TOKEN, CI_JOB_TOKEN, + SYSTEM_ACCESSTOKEN, or BITBUCKET_TOKEN) + + Configuration via CLI options or environment variables: + - `--source-dir` / `SOURCE_DIR` -- Directory where changes are detected (default: '.') + - `--branch-name` / `BRANCH_NAME` -- Full branch name to use. + - `--commit-message` / `COMMIT_MESSAGE` -- Commit message + - `--author-name` / `GIT_AUTHOR_NAME` -- Commit author name (default: "fcli-aviator[bot]") + - `--author-email` / `GIT_AUTHOR_EMAIL` -- Commit author email + +config: + output: immediate + +# NOTE: When updating any options, commit-and-create-pr action may also need to be updated +cli.options: + sourceDir: + names: --source-dir, -s + description: >- + Directory where changes are detected. + Defaults to current working directory if not specified. + required: false + default: "${#env('SOURCE_DIR')?:'.'}" + branchName: + names: --branch-name + description: >- + Full branch name to use. + required: true + default: "${#env('BRANCH_NAME')?:''}" + 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: + - log.debug: "Action configuration: sourceDir=${cli.sourceDir}, branchName=${cli.branchName}, author=${cli.authorName} <${cli.authorEmail}>" + + # Validate git repository + - 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 for push." + - log.info: "Git repository: remote=${gitRepoInfo.repository.remoteUrl}, branch=${gitRepoInfo.branch.short?:'detached HEAD'}" + + # Check for changes + - var.set: + hasChanges: ${#git.hasChanges(cli.sourceDir)} + - if: ${!hasChanges} + do: + - log.info: "No changes detected in ${cli.sourceDir}, skipping commit and push." + - exit: 0 + - log.info: "Changes detected in ${cli.sourceDir}, proceeding." + + # Create and checkout branch + - var.set: + branchName: "${#git.checkoutNewBranch(cli.sourceDir, cli.branchName)}" + on.fail: + - log.warn: + msg: "Failed to create/checkout branch" + cause: ${lastException} + - throw: ${lastException} + - log.info: "Created branch: ${branchName}" + + # Stage all changes + - var.set: + staged: ${#git.addAll(cli.sourceDir)} + on.fail: + - log.warn: + msg: "Failed to stage changes in ${cli.sourceDir}" + cause: ${lastException} + - throw: ${lastException} + - log.debug: "All changes staged successfully" + + # Commit + - var.set: + commitSha: "${#git.commit(cli.sourceDir, cli.commitMessage, cli.authorName, cli.authorEmail)}" + on.fail: + - log.warn: + msg: "Failed to commit staged changes" + cause: ${lastException} + - throw: ${lastException} + - log.info: "Committed changes: ${commitSha}" + - log.debug: "Remote URL: ${gitRepoInfo.repository.remoteUrl}" + + # Push + - var.set: + pushedRef: ${#git.push(cli.sourceDir, branchName)} + on.fail: + - var.set: + remoteUrl: "${gitRepoInfo.repository.remoteUrl?:'unknown'}" + exceptionMsg: "${lastException.message}" + - log.warn: + msg: "Failed to push branch '${branchName}' to remote '${remoteUrl}': ${exceptionMsg}" + cause: ${lastException} + - if: "${exceptionMsg.contains('No credentials available')}" + log.warn: "DIAGNOSIS: Push failed due to missing credentials. Set one of: GITHUB_TOKEN/GH_TOKEN, CI_JOB_TOKEN, SYSTEM_ACCESSTOKEN, or BITBUCKET_TOKEN" + - throw: + msg: "Failed to push branch '${branchName}' to remote" + cause: ${lastException} + - log.info: "Pushed branch to remote: ${pushedRef}" + - log.info: "git-push-changes completed: branch=${branchName}, commit=${commitSha}" 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-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/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/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/credential/ActionAdoCredentialsProvider.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionAdoCredentialsProvider.java new file mode 100644 index 00000000000..ea69a1ccf36 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionAdoCredentialsProvider.java @@ -0,0 +1,57 @@ +/* + * 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.credential; + +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Provides Azure DevOps credentials for Git operations. + * Retrieves Azure DevOps token from SYSTEM_ACCESSTOKEN environment variable (set by ADO Pipeline). + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public class ActionAdoCredentialsProvider implements IActionCredentialsProvider { + + @Override + public CredentialsProvider getCredentialsProvider() { + String token = getAdoToken(); + if (token == null) { + return null; + } + // Azure DevOps uses empty string as username with token as password + return new UsernamePasswordCredentialsProvider("", token); + } + + @Override + public String getCiSystemType() { + return "ado"; + } + + @Override + public boolean isAvailable() { + return getAdoToken() != null; + } + + private String getAdoToken() { + String token = EnvHelper.env("SYSTEM_ACCESSTOKEN"); + if (token != null && !token.isBlank()) { + return token; + } + return null; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionBitbucketCredentialsProvider.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionBitbucketCredentialsProvider.java new file mode 100644 index 00000000000..be56dc901fd --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionBitbucketCredentialsProvider.java @@ -0,0 +1,57 @@ +/* + * 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.credential; + +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Provides Bitbucket credentials for Git operations. + * Retrieves Bitbucket token from BITBUCKET_TOKEN environment variable. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public class ActionBitbucketCredentialsProvider implements IActionCredentialsProvider { + + @Override + public CredentialsProvider getCredentialsProvider() { + String token = getBitbucketToken(); + if (token == null) { + return null; + } + // Bitbucket uses "x-token-auth" as username with token as password + return new UsernamePasswordCredentialsProvider("x-token-auth", token); + } + + @Override + public String getCiSystemType() { + return "bitbucket"; + } + + @Override + public boolean isAvailable() { + return getBitbucketToken() != null; + } + + private String getBitbucketToken() { + String token = EnvHelper.env("BITBUCKET_TOKEN"); + if (token != null && !token.isBlank()) { + return token; + } + return null; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitHubCredentialsProvider.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitHubCredentialsProvider.java new file mode 100644 index 00000000000..2ce876da995 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitHubCredentialsProvider.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.credential; + +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Provides GitHub credentials for Git operations. + * Retrieves GitHub token from GITHUB_TOKEN or GH_TOKEN environment variables. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public class ActionGitHubCredentialsProvider implements IActionCredentialsProvider { + + @Override + public CredentialsProvider getCredentialsProvider() { + String token = getGitHubToken(); + if (token == null) { + return null; + } + // GitHub uses "x-access-token" as username with token as password + return new UsernamePasswordCredentialsProvider("x-access-token", token); + } + + @Override + public String getCiSystemType() { + return "github"; + } + + @Override + public boolean isAvailable() { + return getGitHubToken() != null; + } + + private String getGitHubToken() { + // Check GITHUB_TOKEN first (standard GitHub Actions variable) + String token = EnvHelper.env("GITHUB_TOKEN"); + if (token != null && !token.isBlank()) { + return token; + } + // Fall back to GH_TOKEN (GitHub CLI standard variable) + token = EnvHelper.env("GH_TOKEN"); + if (token != null && !token.isBlank()) { + return token; + } + return null; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitLabCredentialsProvider.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitLabCredentialsProvider.java new file mode 100644 index 00000000000..1d13f365e5c --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/ActionGitLabCredentialsProvider.java @@ -0,0 +1,57 @@ +/* + * 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.credential; + +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.util.EnvHelper; + +/** + * Provides GitLab credentials for Git operations. + * Retrieves GitLab token from CI_JOB_TOKEN environment variable (set by GitLab CI/CD). + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public class ActionGitLabCredentialsProvider implements IActionCredentialsProvider { + + @Override + public CredentialsProvider getCredentialsProvider() { + String token = getGitLabToken(); + if (token == null) { + return null; + } + // GitLab uses "gitlab-ci-token" as username with token as password + return new UsernamePasswordCredentialsProvider("gitlab-ci-token", token); + } + + @Override + public String getCiSystemType() { + return "gitlab"; + } + + @Override + public boolean isAvailable() { + return getGitLabToken() != null; + } + + private String getGitLabToken() { + String token = EnvHelper.env("CI_JOB_TOKEN"); + if (token != null && !token.isBlank()) { + return token; + } + return null; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/CredentialsProviderFactory.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/CredentialsProviderFactory.java new file mode 100644 index 00000000000..a401a033fb5 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/CredentialsProviderFactory.java @@ -0,0 +1,100 @@ +/* + * 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.credential; + +import java.util.Arrays; +import java.util.List; + +import org.eclipse.jgit.transport.CredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Factory for creating and detecting CI system credentials providers. + * Manages credential provider instantiation and selection based on CI system or availability. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public class CredentialsProviderFactory { + + // Ordered list of providers - GitHub checked first to maintain backward compatibility + private static final List PROVIDERS = Arrays.asList( + new ActionGitHubCredentialsProvider(), + new ActionGitLabCredentialsProvider(), + new ActionAdoCredentialsProvider(), + new ActionBitbucketCredentialsProvider() + ); + + /** + * Get the credentials provider for a specific CI system. + * + * @param ciSystemType "github", "gitlab", "ado", or "bitbucket" + * @return IActionCredentialsProvider for the specified system, or null if not found + */ + public static IActionCredentialsProvider getProvider(String ciSystemType) { + if (ciSystemType == null) { + return null; + } + + return PROVIDERS.stream() + .filter(p -> p.getCiSystemType().equalsIgnoreCase(ciSystemType)) + .findFirst() + .orElse(null); + } + + /** + * Auto-detect and return the first available credentials provider. + * Checks providers in order: GitHub, GitLab, ADO, Bitbucket. + * This maintains backward compatibility where GitHub token takes priority. + * + * @return IActionCredentialsProvider for the first detected CI system, + * or null if no credentials are available + */ + public static IActionCredentialsProvider detectAndGetProvider() { + return PROVIDERS.stream() + .filter(IActionCredentialsProvider::isAvailable) + .findFirst() + .orElse(null); + } + + /** + * Get JGit CredentialsProvider from auto-detected CI system. + * Convenience method combining detection and credential provider retrieval. + * + * @return JGit CredentialsProvider for the detected CI system, + * or null if no credentials are available + */ + public static CredentialsProvider detectAndGetJGitProvider() { + IActionCredentialsProvider provider = detectAndGetProvider(); + return provider != null ? provider.getCredentialsProvider() : null; + } + + /** + * Check if any credentials are available. + * + * @return true if at least one CI system has credentials configured + */ + public static boolean hasCredentials() { + return PROVIDERS.stream().anyMatch(IActionCredentialsProvider::isAvailable); + } + + /** + * Get all registered credential providers. + * + * @return List of all IActionCredentialsProvider implementations + */ + public static List getAllProviders() { + return PROVIDERS; + } +} diff --git a/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/IActionCredentialsProvider.java b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/IActionCredentialsProvider.java new file mode 100644 index 00000000000..f3055313f9a --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/credential/IActionCredentialsProvider.java @@ -0,0 +1,48 @@ +/* + * 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.credential; + +import org.eclipse.jgit.transport.CredentialsProvider; + +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * Provides CI system-specific credentials for Git operations. + * Each implementation handles token retrieval and formatting for its respective CI system. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public interface IActionCredentialsProvider { + /** + * Get the JGit CredentialsProvider for this CI system. + * + * @return JGit CredentialsProvider configured with appropriate token and username format, + * or null if credentials are not available + */ + CredentialsProvider getCredentialsProvider(); + + /** + * Get the CI system type identifier. + * + * @return "github", "gitlab", "ado", or "bitbucket" + */ + String getCiSystemType(); + + /** + * Check if credentials are available for this CI system. + * + * @return true if required environment variable is set, false otherwise + */ + boolean isAvailable(); +} 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..b0d08499669 --- /dev/null +++ b/fcli-core/fcli-common-action/src/main/java/com/fortify/cli/common/action/helper/git/ActionGitSpelFunctions.java @@ -0,0 +1,569 @@ +/* + * 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.IOException; +import java.nio.file.Path; + +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.storage.file.FileRepositoryBuilder; +import org.eclipse.jgit.transport.RefSpec; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.action.helper.credential.CredentialsProviderFactory; +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.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: { + id: { 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") + 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 ? "origin" : 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 org.eclipse.jgit.revwalk.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 = "Checks whether the working tree of the git repository at the given directory has any uncommitted changes (modified, added, or deleted files).", returns = "`true` if there are uncommitted changes, `false` otherwise or if not a git repository") + public boolean hasChanges( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + try (var git = openGit(sourceDir)) { + if (git == null) { + return false; + } + var status = git.status().call(); + boolean hasChanges = !status.getModified().isEmpty() + || !status.getAdded().isEmpty() + || !status.getRemoved().isEmpty() + || !status.getUntracked().isEmpty() + || !status.getChanged().isEmpty(); + return hasChanges; + } catch (Exception e) { + return false; + } + } + + @SpelFunction(cat = util, desc = "Creates a new branch with the given full name in the local git repository and checks it out.", returns = "The name of the created branch") + public String checkoutNewBranch( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @SpelFunctionParam(name = "branchName", desc = "full branch name to create and checkout") String branchName) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + git.checkout() + .setCreateBranch(true) + .setName(branchName) + .call(); + String current = git.getRepository().getBranch(); + if (!branchName.equals(current)) { + throw new FcliSimpleException("Failed to checkout branch " + branchName); + } + return branchName; + } catch (GitAPIException | IOException e) { + throw new FcliSimpleException("Failed to create branch: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = "Stages all modified and new files in the working tree for commit.", returns = "`true` if files were staged successfully") + public boolean addAll( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + try (var git = openGit(sourceDir)) { + if (git == null) { + throw new FcliSimpleException("Not a git repository: " + sourceDir); + } + git.add().setUpdate(true).addFilepattern(".").call(); + return true; + } catch (GitAPIException e) { + throw new FcliSimpleException("Failed to stage files: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = "Commits all staged changes in the local git repository with the given message.", returns = "The commit SHA of the new commit") + public String commit( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir, + @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); + } + + if (git.status().call().isClean()) { + throw new FcliSimpleException("No changes to commit"); + } + + var commitResult = git.commit() + .setMessage(message) + .setAuthor(name, email) + .setCommitter(name, email) + .call(); + var sha = commitResult.getId().getName(); + return sha; + } catch (GitAPIException e) { + throw new FcliSimpleException("Failed to commit: " + e.getMessage()); + } + } + + @SpelFunction(cat = util, desc = "Pushes the current branch to the remote repository. Uses token-based authentication from CI environment variables (GITHUB_TOKEN, CI_JOB_TOKEN, SYSTEM_ACCESSTOKEN, BITBUCKET_TOKEN) if available.", 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 = selectRemote(repo); + if (remote == null) + remote = "origin"; + try { + git.checkout().setName(branchName).call(); + } catch (Exception e) { + git.checkout() + .setCreateBranch(true) + .setName(branchName) + .setStartPoint("HEAD") + .call(); + } + + var remoteUrl = repo.getConfig().getString("remote", remote, "url"); + if (remoteUrl != null && !remoteUrl.endsWith(".git")) { + remoteUrl = remoteUrl + ".git"; + repo.getConfig().setString("remote", remote, "url", remoteUrl); + repo.getConfig().save(); + } + var credentialsProvider = CredentialsProviderFactory.detectAndGetJGitProvider(); + if (credentialsProvider == null) { + log.debug("PUSH DEBUG: No credentials provider detected - push will likely fail"); + } else { + log.debug("PUSH DEBUG: Using credentials provider={}", credentialsProvider.getClass().getName()); + } + + String fullBranchRef = "refs/heads/" + branchName; + log.debug("PUSH DETAILS: branch={}, remote={}, remoteUrl={}, fullBranchRef={}", + branchName, + remote, + remoteUrl, + fullBranchRef); + var refSpec = new RefSpec(fullBranchRef + ":" + fullBranchRef); + if (credentialsProvider != null) { + log.debug("CREDENTIALS: type={}, class={}", + credentialsProvider.getClass().getSimpleName(), + credentialsProvider.getClass().getName()); + } + + log.debug("PUSH COMMAND SETUP: remote={}, refSpec={}, timeout=300s, credentialsSet={}", + remote, + refSpec.toString(), + credentialsProvider != null); + var fetchCmd = git.fetch().setRemote(remote); + if (credentialsProvider != null) { + fetchCmd.setCredentialsProvider(credentialsProvider); + } + + try { + var fetchResult = fetchCmd.call(); + log.debug("Fetch completed with {} ref updates", + fetchResult != null ? fetchResult.getAdvertisedRefs().size() : 0); + } catch (Exception e) { + log.warn("Fetch failed (but continuing with push): {}", e.getMessage(), e); + } + + var pushCmd = git.push() + .setRemote(remote) + .setRefSpecs(refSpec) + .setTimeout(300); + + if (credentialsProvider != null) { + pushCmd.setCredentialsProvider(credentialsProvider); + } + var results = pushCmd.call(); + + log.debug("Push command completed. credentialsProvider: {}", + credentialsProvider != null ? credentialsProvider.getClass().getSimpleName() : "null"); + + // Don't convert to ArrayList - just iterate directly + // We can't use .isEmpty() or .size() on Iterable, so remove those checks + + StoredConfig config = repo.getConfig(); + config.setString("branch", branchName, "remote", remote); + config.setString("branch", branchName, "merge", fullBranchRef); + config.save(); + + boolean success = false; + boolean hasResults = false; + for (var result : results) { + hasResults = true; + var messages = result.getMessages(); + if (messages != null && !messages.isBlank()) { + log.debug("Push result messages: {}", messages); + } + + for (var update : result.getRemoteUpdates()) { + var status = update.getStatus(); + log.debug("Push update: status={}, remoteName={}, message='{}', forceUpdate={}", + status, + update.getRemoteName(), + update.getMessage() != null ? update.getMessage() : "null", + update.isForceUpdate()); + switch (status) { + case OK: + case UP_TO_DATE: + success = true; + log.debug("Push successful: {}", update.getRemoteName()); + break; + + case REJECTED_NONFASTFORWARD: + case REJECTED_NODELETE: + case REJECTED_REMOTE_CHANGED: + case REJECTED_OTHER_REASON: + case NON_EXISTING: + case NOT_ATTEMPTED: + default: + throw new FcliSimpleException( + "Push rejected: " + + "status=" + status + + ", remote=" + update.getRemoteName() + + ", message=" + + (update.getMessage() != null ? update.getMessage() : "no message")); + } + } + } + + if (!hasResults) { + log.warn("Push returned empty results - push may have failed silently"); + throw new FcliSimpleException("Push completed but returned no results"); + } + + if (!success) { + throw new FcliSimpleException( + "Push completed but no refs were updated (likely auth or permission issue)"); + } + return fullBranchRef; + } catch (Exception e) { + Throwable root = e; + while (root.getCause() != null) { + root = root.getCause(); + } + throw new FcliSimpleException( + "Failed to push (root cause): " + root.getClass().getName() + " - " + root.getMessage(), + e); + } + } + + @SpelFunction(cat = util, desc = "Detects the repository owner from CI environment variables. Checks GITHUB_REPOSITORY_OWNER (GitHub), CI_PROJECT_NAMESPACE (GitLab), BUILD_REPOSITORY_ID (Azure DevOps), or BITBUCKET_WORKSPACE (Bitbucket). Returns null if not running in a supported CI system.", returns = "The repository owner/namespace or null if not detectable") + public String ciRepositoryOwner() { + var owner = EnvHelper.env("GITHUB_REPOSITORY_OWNER"); + if (StringUtils.isNotBlank(owner)) { + return owner; + } + owner = EnvHelper.env("CI_PROJECT_NAMESPACE"); + if (StringUtils.isNotBlank(owner)) { + return owner; + } + var buildRepoId = EnvHelper.env("BUILD_REPOSITORY_ID"); + owner = EnvHelper.env("SYSTEM_TEAMPROJECT"); + if (StringUtils.isNotBlank(buildRepoId) && StringUtils.isNotBlank(owner)) { + return owner; + } + owner = EnvHelper.env("BITBUCKET_WORKSPACE"); + if (StringUtils.isNotBlank(owner)) { + return owner; + } + return null; + } + + @SpelFunction(cat = util, desc = "Detects the default branch of the remote repository. Checks CI environment variables (CI_DEFAULT_BRANCH for GitLab, looks up via GitHub API env), then falls back to reading refs/remotes/origin/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 remoteHead = repo.resolve("refs/remotes/origin/HEAD"); + if (remoteHead != null) { + var ref = repo.exactRef("refs/remotes/origin/HEAD"); + if (ref != null && ref.getTarget() != null) { + var target = ref.getTarget().getName(); + if (target.startsWith("refs/remotes/origin/")) { + return target.substring("refs/remotes/origin/".length()); + } + } + } + } catch (Exception e) { + log.debug("Error detecting default branch", e); + } + return null; + } + + @SpelFunction(cat = util, desc = """ + Detects the hosting platform of the repository by parsing the git remote URL. + Returns "github" for GitHub-hosted repositories (github.com or *.github.com), + "gitlab" for GitLab-hosted repositories (gitlab.com or hostnames containing "gitlab"), + and "unknown" for any other remote or when detection fails. + This is platform detection (where the repo lives), not CI detection (where the pipeline runs). + """, returns = "\"github\", \"gitlab\", or \"unknown\"") + public String repositoryPlatform( + @SpelFunctionParam(name = "sourceDir", desc = "directory inside a git working tree") String sourceDir) { + try (var git = openGit(sourceDir)) { + if (git == null) { + return "unknown"; + } + var repo = git.getRepository(); + var remote = selectRemote(repo); + if (remote == null) { + return "unknown"; + } + var remoteUrl = repo.getConfig().getString("remote", remote, "url"); + return detectPlatformFromUrl(remoteUrl); + } catch (Exception e) { + log.debug("Failed to detect repository platform", e); + return "unknown"; + } + } + + 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 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 = java.net.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 }; + } + } + + private static String detectPlatformFromUrl(String remoteUrl) { + if (StringUtils.isBlank(remoteUrl)) { + return "unknown"; + } + try { + String host; + var cleaned = remoteUrl.trim(); + if (cleaned.startsWith("git@")) { + // SSH: git@github.com:owner/repo.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"; + } + } catch (Exception e) { + log.debug("Failed to parse remote URL for platform detection: {}", remoteUrl); + } + return "unknown"; + } +} 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..27bf5b7acf8 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 @@ -29,6 +29,7 @@ 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.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; @@ -237,6 +238,7 @@ protected final void configureSpelContext(SimpleEvaluationContext spelContext) { registerCiVariables(spelContext, actionRunnerContext); } spelContext.setVariable("fs", ActionFileSystemSpelFunctions.INSTANCE); + spelContext.setVariable("git", ActionGitSpelFunctions.INSTANCE); spelContext.setVariable("fcli", FcliCommandsSpelFunctions.INSTANCE); } 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..7ad54d725f7 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,7 +293,31 @@ public ObjectNode createReviewComment(String pullNumber, String commitId, .getBody(); } - // === Branch and Commit Operations === + /** + * 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 === /** * Query builder for branches. 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..f12b0790c58 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,7 +134,30 @@ public ObjectNode createMergeRequestNote(String mergeRequestIid, String body) { .getBody(); } - // === Branch and Commit Operations === + /** + * 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 === /** * Query builder for branches. 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..e20e2624ce0 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,34 @@ steps: on.success: - var.set: { postScan.skipReason: } # Reset postScan.skipReason to allow post-scan tasks to run + APPLY_REMEDIATIONS: + cmd: ${#fcliCmd('APPLY_REMEDIATIONS', 'fod aviator apply-remediations')} "--rel=${global.ci.rel}" "--source-dir=${global.ci.sourceDir}" "--progress=none" + skip.if-reason: + - ${#fcliCmdSkipFromEnvReason('APPLY_REMEDIATIONS', true)} # Skip unless DO_APPLY_REMEDIATIONS==true or APPLY_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${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 + on.success: + - var.set: + remediationsApplied: 'true' + branchName: "aviator/remediations/${#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + + GIT_PUSH_CHANGES: + cmd: ${#fcliCmd('GIT_PUSH_CHANGES', 'action run git-push-changes')} "--source-dir=${global.ci.sourceDir}" "--branch-name=${branchName}" "--progress=none" + skip.if-reason: + - ${#isBlank(remediationsApplied)?'Apply remediations was skipped or failed, skipping Git operations':''} + on.fail: + - log.warn: "Git stage/commit/push failed" + + CREATE_PR: + cmd: ${#fcliCmd('CREATE_PR', 'action run create-pr')} "--source-dir=${global.ci.sourceDir}" "--source-branch=${branchName}" "--progress=none" + skip.if-reason: + - ${GIT_PUSH_CHANGES.dependencySkipReason} + on.fail: + - log.warn: + msg: "PR/MR creation failed" + cause: ${lastException} + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'fod', 'check-policy')} "--rel=${global.ci.rel}" "--progress=none" stdout: collect 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..6651149069c 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,46 @@ 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' : ''}" + APPLY_REMEDIATIONS: + cmd: ${#fcliCmd('APPLY_REMEDIATIONS', 'aviator ssc apply-remediations')} "--source-dir=${global.ci.sourceDir}" "--artifact-id=${aviator.artifactId}" "--progress=none" + skip.if-reason: + - ${#fcliCmdSkipFromEnvReason('APPLY_REMEDIATIONS', true)} # Skip unless DO_APPLY_REMEDIATIONS==true or APPLY_REMEDIATIONS_ACTION/EXTRA_OPTS defined + - ${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) + on.success: + - var.set: + remediationsApplied: 'true' + branchName: "aviator/remediations/${#formatDateTime('yyyyMMdd-HHmmss-SSS')}" + + GIT_PUSH_CHANGES: + cmd: ${#fcliCmd('GIT_PUSH_CHANGES', 'action run git-push-changes')} "--source-dir=${global.ci.sourceDir}" "--branch-name=${branchName}" "--progress=none" + skip.if-reason: + - ${#isBlank(remediationsApplied)?'Apply remediations was skipped or failed, skipping Git operations':''} + on.fail: + - log.warn: "Git stage/commit/push failed" + + CREATE_PR: + cmd: ${#fcliCmd('CREATE_PR', 'action run create-pr')} "--source-dir=${global.ci.sourceDir}" "--source-branch=${branchName}" "--progress=none" + skip.if-reason: + - ${GIT_PUSH_CHANGES.dependencySkipReason} + on.fail: + - log.warn: + msg: "PR/MR creation failed" + cause: ${lastException} + CHECK_POLICY: cmd: ${#actionCmd('CHECK_POLICY', 'ssc', 'check-policy')} "--av=${global.ci.av}" "--progress=none" stdout: collect @@ -283,4 +314,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/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)