diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0f75b066..a4c81707 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,19 +5,22 @@ title: '' labels: '' assignees: '' --- - -**Describe the bug** -A clear and concise description of what the bug is. +### Describe the bug +A clear and concise description of what the bug is. Add a screenshot if it is relevant. **Describe the bug:** -**Steps to reproduce:** +### Steps to reproduce + +### Expected behavior -**Expected behavior:** -**Additional context:** - +### Additional context + +Plugin version: +IDE: +OS: diff --git a/src/main/kotlin/com/dsoftware/ghmanager/PluginErrorReportSubmitter.kt b/src/main/kotlin/com/dsoftware/ghmanager/PluginErrorReportSubmitter.kt new file mode 100644 index 00000000..d35b20e3 --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/PluginErrorReportSubmitter.kt @@ -0,0 +1,74 @@ +package com.dsoftware.ghmanager + +import com.intellij.ide.BrowserUtil +import com.intellij.ide.plugins.PluginManagerCore.getPlugin +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.diagnostic.ErrorReportSubmitter +import com.intellij.openapi.diagnostic.IdeaLoggingEvent +import com.intellij.openapi.diagnostic.SubmittedReportInfo +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.Consumer +import java.awt.Component +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +internal class PluginErrorReportSubmitter : ErrorReportSubmitter() { + private val REPORT_URL = + "https://github.com/cunla/ghactions-manager/issues/new?assignees=&labels=&projects=&template=bug_report.md" + + override fun getReportActionText(): String { + return "Report Issue on Plugin Issues Tracker" + } + + override fun submit( + events: Array, + additionalInfo: String?, + parentComponent: Component, + consumer: Consumer + ): Boolean { + val event = events[0] + val throwableTitle = event.throwableText.lines()[0] + + val sb = StringBuilder(REPORT_URL) + + val titleEncoded = URLEncoder.encode(event.throwable?.message ?: throwableTitle, StandardCharsets.UTF_8) + sb.append("&title=${titleEncoded}") + + val pluginVersion = getPlugin(pluginDescriptor.pluginId)?.version ?: "unknown" + + val body = """ + ### Describe the bug + A clear and concise description of what the bug is. + Add a screenshot if it is relevant. + + **Describe the bug:** + ${additionalInfo ?: ""} + ${event.message ?: ""} + + #### Stack trace + + {{{PLACEHOLDER}}} + + ### Steps to reproduce + + + + ### Expected behavior + + + + ### Additional context + + Plugin version: $pluginVersion + IDE: ${ApplicationInfo.getInstance().fullApplicationName} (${ApplicationInfo.getInstance().build.asString()}) + OS: ${SystemInfo.getOsNameAndVersion()} + + """.trimIndent().replace("{{{PLACEHOLDER}}}", event.throwableText) + sb.append("&body=${URLEncoder.encode(body, StandardCharsets.UTF_8)}") + BrowserUtil.browse(sb.toString()) + + consumer.consume(SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.NEW_ISSUE)) + return true + } + +} diff --git a/src/main/kotlin/com/dsoftware/ghmanager/data/GhActionsService.kt b/src/main/kotlin/com/dsoftware/ghmanager/data/GhActionsService.kt index 077d1195..b5d03d7b 100644 --- a/src/main/kotlin/com/dsoftware/ghmanager/data/GhActionsService.kt +++ b/src/main/kotlin/com/dsoftware/ghmanager/data/GhActionsService.kt @@ -1,6 +1,7 @@ package com.dsoftware.ghmanager.data import com.dsoftware.ghmanager.ui.GhActionsMgrToolWindowContent +import com.dsoftware.ghmanager.ui.settings.GhActionsSettingsService import com.intellij.openapi.Disposable import com.intellij.openapi.components.service import com.intellij.openapi.project.Project @@ -23,6 +24,8 @@ interface GhActionsService { val gitHubAccounts: Set val accountsState: StateFlow> val toolWindowsJobMap: MutableMap + + fun guessAccountForRepository(repo: GHGitRepositoryMapping): GithubAccount? { return gitHubAccounts.firstOrNull { it.server.equals(repo.repository.serverPath, true) } } @@ -46,8 +49,9 @@ interface GhActionsService { } } -open class GhActionsServiceImpl(project: Project, override val coroutineScope: CoroutineScope) : GhActionsService, - Disposable { +open class GhActionsServiceImpl( + project: Project, override val coroutineScope: CoroutineScope +) : GhActionsService, Disposable { private val repositoriesManager = project.service() private val accountManager = service() diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubActionCache.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubActionCache.kt new file mode 100644 index 00000000..ffeb1005 --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubActionCache.kt @@ -0,0 +1,187 @@ +package com.dsoftware.ghmanager.psi + +import com.dsoftware.ghmanager.api.GhApiRequestExecutor +import com.dsoftware.ghmanager.data.GhActionsService +import com.dsoftware.ghmanager.psi.model.GitHubAction +import com.dsoftware.ghmanager.ui.settings.GhActionsSettingsService +import com.fasterxml.jackson.databind.JsonNode +import com.intellij.collaboration.api.dto.GraphQLRequestDTO +import com.intellij.collaboration.api.dto.GraphQLResponseDTO +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.util.ResourceUtil +import com.intellij.util.ThrowableConvertor +import org.jetbrains.plugins.github.api.GithubApiContentHelper +import org.jetbrains.plugins.github.api.GithubApiRequest.Post +import org.jetbrains.plugins.github.api.GithubApiResponse +import org.jetbrains.plugins.github.api.data.graphql.GHGQLError +import org.jetbrains.plugins.github.exceptions.GithubAuthenticationException +import org.jetbrains.plugins.github.exceptions.GithubConfusingException +import org.jetbrains.plugins.github.exceptions.GithubJsonException +import org.jetbrains.plugins.github.util.GHCompatibilityUtil +import java.io.IOException + +@Service(Service.Level.PROJECT) +@State(name = "GitHubActionCache", storages = [Storage("githubActionCache.xml")]) +class GitHubActionCache(project: Project) : PersistentStateComponent { + private var state = State() + + private val ghActionsService = project.service() + private val settingsService = project.service() + private val serverPath: String + private var requestExecutor: GhApiRequestExecutor? = null + + init { + this.serverPath = determineServerPath() + ghActionsService.gitHubAccounts.firstOrNull()?.let { account -> + val token = if (settingsService.state.useGitHubSettings) { + GHCompatibilityUtil.getOrRequestToken(account, project) + } else { + settingsService.state.apiToken + } + requestExecutor = if (token == null) null else GhApiRequestExecutor.create(token) + } + } + + class State { + val actions = TimedCache() + } + + override fun loadState(state: State) { + this.state = state + } + + fun cleanup() { + state.actions.cleanup() + } + + private fun determineServerPath(): String { + val mappings = ghActionsService.knownRepositoriesState.value + if (mappings.isEmpty()) { + LOG.info("No repository mappings, using default graphql url") + return "https://api.github.com/graphql" + } else { + val mapping = mappings.iterator().next() + return mapping.repository.serverPath.toGraphQLUrl() + } + } + + fun getAction(fullActionName: String): GitHubAction? { + if (state.actions.containsKey(fullActionName)) { + return state.actions[fullActionName] + } + val requestExecutor = this.requestExecutor + if (requestExecutor == null) { + LOG.warn("Failed to get latest version of action $fullActionName: no GitHub account found") + return null + } + val actionOrg = fullActionName.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0] + val actionName = fullActionName.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1] + val query = ResourceUtil.getResource( + GhActionsService::class.java.classLoader, + "graphql/query", + "getLatestRelease.graphql" + )?.readText() ?: "" + + val request = TraversedParsed( + serverPath, + query, + mapOf("owner" to actionOrg, "name" to actionName), + JsonNode::class.java, + "repository", + "latestRelease", + "tag", + "name" + ) + try { + val response = requestExecutor.execute(request) + val version = response.toString().replace("\"", "") + state.actions[actionName] = GitHubAction(actionName, version) + return state.actions[actionName] + } catch (e: IOException) { + LOG.warn("Failed to get latest version of action $fullActionName", e) + } + return null + } + + fun resolveActionsAsync(actions: List) { + actions.forEach { actionName -> + if (!state.actions.containsKey(actionName)) { + getAction(actionName) + } + } + } + + + companion object { + private val LOG = logger() + } + + + class TraversedParsed( + url: String, + private val query: String, + private val variablesObject: Any, + private val clazz: Class, + private vararg val pathFromData: String + ) : Post(GithubApiContentHelper.JSON_MIME_TYPE, url) { + + override val body: String + get() = GithubApiContentHelper.toJson(GraphQLRequestDTO(query, variablesObject), true) + + + protected fun throwException(errors: List): Nothing { + if (errors.any { it.type.equals("INSUFFICIENT_SCOPES", true) }) + throw GithubAuthenticationException("Access token has not been granted the required scopes.") + + if (errors.size == 1) throw GithubConfusingException(errors.single().toString()) + throw GithubConfusingException(errors.toString()) + } + + override fun extractResult(response: GithubApiResponse): T { + return parseResponse(response, clazz, pathFromData) + ?: throw GithubJsonException("Non-nullable entity is null or entity path is invalid") + } + + private fun parseResponse( + response: GithubApiResponse, + clazz: Class, + pathFromData: Array + ): T? { + val result: GraphQLResponseDTO = parseGQLResponse(response, JsonNode::class.java) + val data = result.data + if (data != null && !data.isNull) { + var node: JsonNode = data + for (path in pathFromData) { + node = node[path] ?: break + } + if (!node.isNull) return GithubApiContentHelper.fromJson(node.toString(), clazz, true) + } + val errors = result.errors + if (errors == null) return null + else throwException(errors) + } + + private fun parseGQLResponse( + response: GithubApiResponse, + dataClass: Class + ): GraphQLResponseDTO { + return response.readBody(ThrowableConvertor { + @Suppress("UNCHECKED_CAST") + GithubApiContentHelper.readJsonObject( + it, GraphQLResponseDTO::class.java, dataClass, GHGQLError::class.java, + gqlNaming = true + ) as GraphQLResponseDTO + }) + } + } + + override fun getState(): State = state + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubWorkflowConfig.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubWorkflowConfig.kt new file mode 100644 index 00000000..bfba95aa --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/GitHubWorkflowConfig.kt @@ -0,0 +1,251 @@ +package com.dsoftware.ghmanager.psi + +import java.util.regex.Pattern + +object GitHubWorkflowConfig { + val PATTERN_GITHUB_OUTPUT: Pattern = + Pattern.compile("echo\\s+\"(\\w+)=(.*?)\"\\s*>>\\s*\"?\\$\\w*:?\\{?GITHUB_OUTPUT\\}?\"?") + val PATTERN_GITHUB_ENV: Pattern = + Pattern.compile("echo\\s+\"(\\w+)=(.*?)\"\\s*>>\\s*\"?\\$\\w*:?\\{?GITHUB_ENV\\}?\"?") + const val CACHE_ONE_DAY: Long = 24L * 60 * 60 * 1000 + const val FIELD_ON: String = "on" + const val FIELD_IF: String = "if" + const val FIELD_ID: String = "id" + const val FIELD_ENVS: String = "env" + const val FIELD_RUN: String = "run" + const val FIELD_RUNS: String = "runs" + const val FIELD_JOBS: String = "jobs" + const val FIELD_VARS: String = "vars" + const val FIELD_WITH: String = "with" + const val FIELD_USES: String = "uses" + const val FIELD_NEEDS: String = "needs" + const val FIELD_STEPS: String = "steps" + const val FIELD_RUNNER: String = "runner" + const val FIELD_GITHUB: String = "github" + const val FIELD_INPUTS: String = "inputs" + const val FIELD_OUTPUTS: String = "outputs" + const val FIELD_SECRETS: String = "secrets" + const val FIELD_CONCLUSION: String = "conclusion" + const val FIELD_OUTCOME: String = "outcome" + + //https://docs.github.com/en/actions/learn-github-actions/contexts#github-context + val FIELD_GITHUB_MAP: Map = mapOf( + Pair( + "action", + "The name of the action currently running, or the id of a step. GitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same action more than once in the same job, the name will include a suffix with the sequence number with underscore before it. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2." + ), Pair( + "action_path", + "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action, for example by changing directories to the path: cd \${{ github.action_path }} ." + ), Pair( + "action_ref", + "For a step executing an action, this is the ref of the action being executed. For example, v2." + ), Pair( + "action_repository", + "For a step executing an action, this is the owner and repository name of the action. For example, actions/checkout." + ), Pair("action_status", "For a composite action, the current result of the composite action."), Pair( + "actor", + "The username of the user that triggered the initial workflow run. If the workflow run is a re-run, this value may differ from github.triggering_actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges." + ), Pair( + "actor_id", + "The account ID of the person or app that triggered the initial workflow run. For example, 1234567. Note that this is different from the actor username." + ), Pair("api_url", "The URL of the GitHub REST API."), Pair( + "base_ref", + "The base_ref or target branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target." + ), Pair( + "env", + "Path on the runner to the file that sets environment variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"Workflow commands for GitHub Actions.\"" + ), Pair( + "event", + "The full event webhook payload. You can access individual properties of the event using this context. This object is identical to the webhook payload of the event that triggered the workflow run, and is different for each event. The webhooks for each GitHub Actions event is linked in \"Events that trigger workflows.\" For example, for a workflow run triggered by the push event, this object contains the contents of the push webhook payload." + ), Pair("event_name", "The name of the event that triggered the workflow run."), Pair( + "event_path", "The path to the file on the runner that contains the full event webhook payload." + ), Pair("graphql_url", "The URL of the GitHub GraphQL API."), Pair( + "head_ref", + "The head_ref or source branch of the pull request in a workflow run. This property is only available when the event that triggers a workflow run is either pull_request or pull_request_target." + ), Pair( + "job", + "The job_id of the current job. \nNote: This context property is set by the Actions runner, and is only available within the execution steps of a job. Otherwise, the value of this property will be null." + ), Pair( + "job_workflow_sha", "For jobs using a reusable workflow, the commit SHA for the reusable workflow file." + ), Pair( + "path", + "Path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and is a different file for each step in a job. For more information, see \"Workflow commands for GitHub Actions.\"" + ), Pair( + "ref", + "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/, for pull requests it is refs/pull//merge, and for tags it is refs/tags/. For example, refs/heads/feature-branch-1." + ), Pair( + "ref_name", + "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1." + ), Pair( + "ref_protected", "true if branch protections are configured for the ref that triggered the workflow run." + ), Pair( + "ref_type", "The type of ref that triggered the workflow run. Valid values are branch or tag." + ), Pair("repository", "The owner and repository name. For example, octocat/Hello-World."), Pair( + "repository_id", + "The ID of the repository. For example, 123456789. Note that this is different from the repository name." + ), Pair("repository_owner", "The repository owner's username. For example, octocat."), Pair( + "repository_owner_id", + "The repository owner's account ID. For example, 1234567. Note that this is different from the owner's name." + ), Pair( + "repositoryUrl", "The Git URL to the repository. For example, git://github.com/octocat/hello-world.git." + ), Pair("retention_days", "The number of days that workflow run logs and artifacts are kept."), Pair( + "run_id", + "A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run." + ), Pair( + "run_number", + "A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run." + ), Pair( + "run_attempt", + "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run." + ), Pair( + "secret_source", + "The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces." + ), Pair("server_url", "The URL of the GitHub server. For example: https://github.com."), Pair( + "sha", + "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see \"Events that trigger workflows.\" For example, ffac537e6cbbf934b08745a378932722df287a53." + ), Pair( + "token", + "A token to authenticate on behalf of the GitHub App installed on your repository. This is functionally equivalent to the GITHUB_TOKEN secret. For more information, see \"Automatic token authentication.\" \nNote: This context property is set by the Actions runner, and is only available within the execution steps of a job. Otherwise, the value of this property will be null." + ), Pair( + "triggering_actor", + "The username of the user that initiated the workflow run. If the workflow run is a re-run, this value may differ from github.actor. Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges." + ), Pair( + "workflow", + "The name of the workflow. If the workflow file doesn't specify a name, the value of this property is the full path of the workflow file in the repository." + ), Pair( + "workflow_ref", + "The ref path to the workflow. For example, octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch." + ), Pair("workflow_sha", "The commit SHA for the workflow file."), Pair( + "workspace", + "The default working directory on the runner for steps, and the default location of your repository when using the checkout action." + ) + ) + + //https://docs.github.com/en/actions/learn-github-actions/variables#using-the-vars-context-to-access-configuration-variable-values + val FIELD_ENVS_MAP: Map = mapOf( + Pair("CI", "Always set to true."), Pair( + "GITHUB_ACTION", + "The name of the action currently running, or the id of a step. For example, for an action, __repo-owner_name-of-action-repo.\n\nGitHub removes special characters, and uses the name __run when the current step runs a script without an id. If you use the same script or action more than once in the same job, the name will include a suffix that consists of the sequence number preceded by an underscore. For example, the first script you run will have the name __run, and the second script will be named __run_2. Similarly, the second invocation of actions/checkout will be actionscheckout2." + ), Pair( + "GITHUB_ACTION_PATH", + "The path where an action is located. This property is only supported in composite actions. You can use this path to access files located in the same repository as the action. For example, /home/runner/work/_actions/repo-owner/name-of-action-repo/v1." + ), Pair( + "GITHUB_ACTION_REPOSITORY", + "For a step executing an action, this is the owner and repository name of the action. For example, actions/checkout." + ), Pair( + "GITHUB_ACTIONS", + "Always set to true when GitHub Actions is running the workflow. You can use this variable to differentiate when tests are being run locally or by GitHub Actions." + ), Pair( + "GITHUB_ACTOR", "The name of the person or app that initiated the workflow. For example, octocat." + ), Pair( + "GITHUB_ACTOR_ID", + "The account ID of the person or app that triggered the initial workflow run. For example, 1234567. Note that this is different from the actor username." + ), Pair("GITHUB_API_URL", "Returns the API URL. For example: https://api.github.com."), Pair( + "GITHUB_BASE_REF", + "The name of the base ref or target branch of the pull request in a workflow run. This is only set when the event that triggers a workflow run is either pull_request or pull_request_target. For example, main." + ), Pair( + "GITHUB_ENV", + "The path on the runner to the file that sets variables from workflow commands. This file is unique to the current step and changes for each step in a job. For example, /home/runner/work/_temp/_runner_file_commands/set_env_87406d6e-4979-4d42-98e1-3dab1f48b13a. For more information, see \"Workflow commands for GitHub Actions.\"" + ), Pair( + "GITHUB_EVENT_NAME", "The name of the event that triggered the workflow. For example, workflow_dispatch." + ), Pair( + "GITHUB_EVENT_PATH", + "The path to the file on the runner that contains the full event webhook payload. For example, /github/workflow/event.json." + ), Pair( + "GITHUB_GRAPHQL_URL", "Returns the GraphQL API URL. For example: https://api.github.com/graphql" + ), Pair( + "GITHUB_HEAD_REF", + "The head ref or source branch of the pull request in a workflow run. This property is only set when the event that triggers a workflow run is either pull_request or pull_request_target. For example, feature-branch-1." + ), Pair("GITHUB_JOB", "The job_id of the current job. For example, greeting_job."), Pair( + "GITHUB_PATH", + "The path on the runner to the file that sets system PATH variables from workflow commands. This file is unique to the current step and changes for each step in a job. For example, /home/runner/work/_temp/_runner_file_commands/add_path_899b9445-ad4a-400c-aa89-249f18632cf5. For more information, see \"Workflow commands for GitHub Actions.\"" + ), Pair( + "GITHUB_REF", + "The fully-formed ref of the branch or tag that triggered the workflow run. For workflows triggered by push, this is the branch or tag ref that was pushed. For workflows triggered by pull_request, this is the pull request merge branch. For workflows triggered by release, this is the release tag created. For other triggers, this is the branch or tag ref that triggered the workflow run. This is only set if a branch or tag is available for the event type. The ref given is fully-formed, meaning that for branches the format is refs/heads/, for pull requests it is refs/pull//merge, and for tags it is refs/tags/. For example, refs/heads/feature-branch-1." + ), Pair( + "GITHUB_REF_NAME", + "The short ref name of the branch or tag that triggered the workflow run. This value matches the branch or tag name shown on GitHub. For example, feature-branch-1." + ), Pair( + "GITHUB_REF_PROTECTED", + "true if branch protections are configured for the ref that triggered the workflow run." + ), Pair( + "GITHUB_REF_TYPE", "The type of ref that triggered the workflow run. Valid values are branch or tag." + ), Pair("GITHUB_REPOSITORY", "The owner and repository name. For example, octocat/Hello-World."), Pair( + "GITHUB_REPOSITORY_ID", + "The ID of the repository. For example, 123456789. Note that this is different from the repository name." + ), Pair( + "GITHUB_REPOSITORY_OWNER", + "The repository owner's account ID. For example, 1234567. Note that this is different from the owner's name." + ), Pair( + "GITHUB_RETENTION_DAYS", + "The number of days that workflow run logs and artifacts are kept. For example, 90." + ), Pair( + "GITHUB_RUN_ATTEMPT", + "A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. For example, 3." + ), Pair( + "GITHUB_RUN_ID", + "A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. For example, 1658821493." + ), Pair( + "GITHUB_RUN_NUMBER", + "A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. For example, 3." + ), Pair("GITHUB_SERVER_URL", "The URL of the GitHub server. For example: https://github.com."), Pair( + "GITHUB_SHA", + "The commit SHA that triggered the workflow. The value of this commit SHA depends on the event that triggered the workflow. For more information, see \"Events that trigger workflows.\" For example, ffac537e6cbbf934b08745a378932722df287a53." + ), Pair( + "GITHUB_STEP_SUMMARY", + "The path on the runner to the file that contains job summaries from workflow commands. This file is unique to the current step and changes for each step in a job. For example, /home/runner/_layout/_work/_temp/_runner_file_commands/step_summary_1cb22d7f-5663-41a8-9ffc-13472605c76c. For more information, see \"Workflow commands for GitHub Actions.\"" + ), Pair( + "GITHUB_WORKFLOW", + "The name of the workflow. For example, My test workflow. If the workflow file doesn't specify a name, the value of this variable is the full path of the workflow file in the repository." + ), Pair( + "GITHUB_WORKFLOW_REF", + "The ref path to the workflow. For example, octocat/hello-world/.github/workflows/my-workflow.yml@refs/heads/my_branch." + ), Pair("GITHUB_WORKFLOW_SHA", "The commit SHA for the workflow file."), Pair( + "GITHUB_WORKSPACE", + "The default working directory on the runner for steps, and the default location of your repository when using the checkout action. For example, /home/runner/work/my-repo-name/my-repo-name." + ), Pair( + "RUNNER_ARCH", + "The architecture of the runner executing the job. Possible values are X86, X64, ARM, or ARM64." + ), Pair( + "RUNNER_DEBUG", + "This is set only if debug logging is enabled, and always has the value of 1. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps." + ), Pair("RUNNER_NAME", "The name of the runner executing the job. For example, Hosted Agent"), Pair( + "RUNNER_OS", + "The operating system of the runner executing the job. Possible values are Linux, Windows, or macOS. For example, Windows" + ), Pair( + "RUNNER_TEMP", + "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them. For example, D:\\a\\_temp" + ), Pair( + "RUNNER_TOOL_CACHE", + "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"About GitHub-hosted runners\". For example, C:\\hostedtoolcache\\windows" + ) + ) + + val FIELD_RUNNER_MAP: Map = mapOf( + "name" to "The name of the runner executing the job.", + "os" to "The operating system of the runner executing the job. Possible values are Linux, Windows, or macOS.", + "arch" to "The architecture of the runner executing the job. Possible values are X86, X64, ARM, or ARM64.", + "temp" to "The path to a temporary directory on the runner. This directory is emptied at the beginning and end of each job. Note that files will not be removed if the runner's user account does not have permission to delete them.", + "tool_cache" to "he path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"About GitHub-hosted runners\".", + "debug" to "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"About GitHub-hosted runners\"." + ) + val FIELD_DEFAULT_MAP: Map = mapOf( + Pair(FIELD_INPUTS, "Workflow inputs e.g. from workflow_dispatch, workflow_call"), + Pair(FIELD_SECRETS, "Workflow secrets"), + Pair(FIELD_JOBS, "Workflow jobs"), + Pair(FIELD_STEPS, "steps with 'id' of the current job"), + Pair(FIELD_ENVS, "Environment variables from jobs amd steps"), + Pair( + FIELD_VARS, + "The vars context contains custom configuration variables set at the organization, repository, and environment levels. For more information about defining configuration variables for use in multiple workflows" + ), + Pair( + FIELD_NEEDS, + "Identifies any jobs that must complete successfully before this job will run. It can be a string or array of strings. If a job fails, all jobs that need it are skipped unless the jobs use a conditional statement that causes the job to continue." + ), + Pair( + FIELD_GITHUB, + "Information about the workflow run and the event that triggered the run. You can also read most of the github context data in environment variables. For more information about environment variables" + ) + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/HighlightAnnotator.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/HighlightAnnotator.kt new file mode 100644 index 00000000..41633d2b --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/HighlightAnnotator.kt @@ -0,0 +1,41 @@ +package com.dsoftware.ghmanager.psi + +import com.dsoftware.ghmanager.psi.GitHubWorkflowConfig.FIELD_USES +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.Annotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.openapi.components.service +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import org.jetbrains.yaml.psi.YAMLKeyValue + +class HighlightAnnotator : Annotator { + override fun annotate(element: PsiElement, holder: AnnotationHolder) { + if (!element.isValid || element !is YAMLKeyValue) { + return + } + val yamlKeyValue = element as YAMLKeyValue + when (yamlKeyValue.keyText) { + FIELD_USES -> highlightAction(yamlKeyValue, holder) + } + } + + private fun highlightAction(yamlKeyValue: YAMLKeyValue, holder: AnnotationHolder) { + val gitHubActionCache = yamlKeyValue.project.service() + val actionName = yamlKeyValue.valueText.split("@").firstOrNull() ?: return + val currentVersion = yamlKeyValue.valueText.split("@").getOrNull(1) + val latestVersion = gitHubActionCache.getAction(actionName)?.latestVersion + if (VersionCompareTools.isActionOutdated(currentVersion, latestVersion)) { + holder.newAnnotation( + HighlightSeverity.WARNING, + "$currentVersion is outdated. Latest version is $latestVersion" + ).range( + TextRange.create( + yamlKeyValue.textRange.startOffset + + yamlKeyValue.text.indexOf("@") + 1, + yamlKeyValue.textRange.endOffset + ) + ).create() + } + } +} diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/ProjectStartup.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/ProjectStartup.kt new file mode 100644 index 00000000..9fa7bf60 --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/ProjectStartup.kt @@ -0,0 +1,43 @@ +package com.dsoftware.ghmanager.psi + +import com.dsoftware.ghmanager.psi.GitHubWorkflowConfig.FIELD_USES +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.components.service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager + +class ProjectStartup : ProjectActivity { + override suspend fun execute(project: Project) { + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFiles.forEach { asyncInitAllActions(project, it) } + + project.messageBus.connect().subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + object : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + asyncInitAllActions(project, file) + } + }) + } + + fun asyncInitAllActions(project: Project, openedFile: VirtualFile) { + if (Tools.isWorkflowPath(openedFile.toNioPath())) { + return + } + runReadAction { + val psiManager = project.service() + val gitHubActionCache = project.service() + psiManager.findFile(openedFile)?.let { + val actions = Tools.getAllElementsWithKey(it, FIELD_USES).map { yamlKeyValue -> + yamlKeyValue.valueText.split("@").firstOrNull() ?: return@map null + }.filterNotNull() + gitHubActionCache.resolveActionsAsync(actions) + } + + } + } +} diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/TimedCache.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/TimedCache.kt new file mode 100644 index 00000000..63f0ae1f --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/TimedCache.kt @@ -0,0 +1,44 @@ +package com.dsoftware.ghmanager.psi + +import com.dsoftware.ghmanager.psi.model.GitHubAction +import java.util.concurrent.ConcurrentHashMap + +class TimedCache(private var cacheTimeValidityInMillis: Long = 1000 * 60 * 60) : + ConcurrentHashMap() { + private val creationTimeMap = ConcurrentHashMap() + + fun cleanup() { + this.forEach { (key, value) -> + if (isExpired(key, cacheTimeValidityInMillis)) { + this.remove(key) + creationTimeMap.remove(key) + } + } + } + + override fun get(key: String): GitHubAction? { + val value = super.get(key) + if (value == null || isExpired(key, cacheTimeValidityInMillis)) { + return null + } + return value + } + + override fun put(key: String, value: GitHubAction): GitHubAction { + super.put(key, value) + creationTimeMap[key] = now() + return value + } + + private fun isExpired(key: String, cacheTimeValidityInMillis: Long): Boolean { + val creationTime = creationTimeMap[key] + return if (creationTime == null) { + true + } else { + (now() - creationTime) > cacheTimeValidityInMillis + } + } + + + private fun now() = System.currentTimeMillis() +} \ No newline at end of file diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/Tools.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/Tools.kt new file mode 100644 index 00000000..d49e19f5 --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/Tools.kt @@ -0,0 +1,50 @@ +package com.dsoftware.ghmanager.psi + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.psi.PsiElement +import org.jetbrains.yaml.psi.YAMLKeyValue +import java.nio.file.Path +import java.util.Collections + +object Tools { + fun isWorkflowPath(path: Path): Boolean { + return (isActionFile(path) || isWorkflowFile(path) || ApplicationManager.getApplication().isUnitTestMode) + } + + fun isActionFile(path: Path): Boolean { + val filename = path.fileName.toString().lowercase() + return filename == "action.yml" || filename == "action.yaml" + } + + fun isWorkflowFile(path: Path): Boolean { + return (path.nameCount > 2 && isYamlFile(path) + && path.getName(path.nameCount - 2).toString().equals("workflows", ignoreCase = true) + && path.getName(path.nameCount - 3).toString().equals(".github", ignoreCase = true)) + } + + fun isYamlFile(path: Path): Boolean { + val filename = path.fileName.toString().lowercase() + return filename.endsWith(".yml") || filename.endsWith(".yaml") + } + + fun getAllElementsWithKey(psiElement: PsiElement?, keyName: String?): List { + if (psiElement == null || keyName == null) { + return emptyList() + } + val results = mutableListOf() + val exploredElements = mutableSetOf() + val toExplore = mutableListOf(psiElement) + while (toExplore.isNotEmpty()) { + val currentElement = toExplore.removeAt(0) + if (exploredElements.contains(currentElement)) { + continue + } + exploredElements.add(currentElement) + if (currentElement is YAMLKeyValue && currentElement.keyText == keyName) { + results.add(currentElement) + } + toExplore.addAll(currentElement.children) + } + return Collections.unmodifiableList(results) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/VersionCompareTools.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/VersionCompareTools.kt new file mode 100644 index 00000000..14a73469 --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/VersionCompareTools.kt @@ -0,0 +1,35 @@ +package com.dsoftware.ghmanager.psi + +import com.intellij.openapi.diagnostic.logger + +object VersionCompareTools { + private val LOG = logger() + + fun isActionOutdated(current: String?, latest: String?): Boolean { + if (current == null || latest == null) { + return false + } + var currentVersion = current + var latestVersion = latest + if (latestVersion.startsWith("v")) { + latestVersion = latestVersion.substring(1) + } + if (currentVersion.startsWith("v")) { + currentVersion = currentVersion.substring(1) + } + LOG.debug( + "Comparing versions: latestVersion=$latestVersion and currentVersion=$currentVersion", + latestVersion, + currentVersion + ) + val majorLatest = latestVersion.split(".").dropLastWhile { it.isEmpty() }.toTypedArray()[0] + val majorCurrent = currentVersion.split(".").dropLastWhile { it.isEmpty() }.toTypedArray()[0] + return try { + majorLatest.toInt() != majorCurrent.toInt() + } catch (e: NumberFormatException) { + false + } + } + + +} diff --git a/src/main/kotlin/com/dsoftware/ghmanager/psi/model/GitHubAction.kt b/src/main/kotlin/com/dsoftware/ghmanager/psi/model/GitHubAction.kt new file mode 100644 index 00000000..2487e57a --- /dev/null +++ b/src/main/kotlin/com/dsoftware/ghmanager/psi/model/GitHubAction.kt @@ -0,0 +1,12 @@ +package com.dsoftware.ghmanager.psi.model + +import kotlinx.serialization.Serializable + +@Serializable +data class GitHubAction( + val name: String, + val latestVersion: String? = null, +) { + val isLocalAction: Boolean + get() = name.startsWith("./") +} \ No newline at end of file diff --git a/src/main/kotlin/com/dsoftware/ghmanager/ui/RepoTabController.kt b/src/main/kotlin/com/dsoftware/ghmanager/ui/RepoTabController.kt index cb363162..737d073b 100644 --- a/src/main/kotlin/com/dsoftware/ghmanager/ui/RepoTabController.kt +++ b/src/main/kotlin/com/dsoftware/ghmanager/ui/RepoTabController.kt @@ -4,8 +4,6 @@ package com.dsoftware.ghmanager.ui import com.dsoftware.ghmanager.actions.ActionKeys import com.dsoftware.ghmanager.data.JobsLoadingModelListener import com.dsoftware.ghmanager.data.LogLoadingModelListener -import com.dsoftware.ghmanager.data.LogValue -import com.dsoftware.ghmanager.data.LogValueStatus import com.dsoftware.ghmanager.data.WorkflowDataContextService import com.dsoftware.ghmanager.data.WorkflowRunSelectionContext import com.dsoftware.ghmanager.i18n.MessagesBundle.message diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index badfe34a..17da9880 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -22,10 +22,15 @@ + + + + + -