diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af6a9d9..b0ff65a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,17 +1,20 @@ -# Dependabot configuration: -# https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates - version: 2 + +multi-ecosystem-groups: + plugin-dependencies: + target-branch: "dev" + schedule: + interval: "weekly" + updates: - # Maintain dependencies for Gradle dependencies - package-ecosystem: "gradle" directory: "/" - target-branch: "main" - schedule: - interval: "weekly" - # Maintain dependencies for GitHub Actions + patterns: + - "*" + multi-ecosystem-group: "plugin-dependencies" + - package-ecosystem: "github-actions" directory: "/" - target-branch: "main" - schedule: - interval: "weekly" + patterns: + - "*" + multi-ecosystem-group: "plugin-dependencies" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b18dea3..77b7020 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag: - description: Release tag. Empty means today's vYYYY.M.D. + description: Release tag. Empty means today's YYYY.M.D. required: false type: string dry_run: @@ -81,20 +81,25 @@ jobs: release_tag="" target_version="$plugin_version" if [ "$release" = "true" ]; then - if [ -n "$INPUT_TAG" ]; then - release_tag="$INPUT_TAG" + input_tag="$INPUT_TAG" + case "$input_tag" in + v*) input_tag="${input_tag#v}" ;; + esac + + if [ -n "$input_tag" ]; then + release_tag="$input_tag" else year="$(date -u +%Y)" month="$(date -u +%m | sed 's/^0//')" day="$(date -u +%d | sed 's/^0//')" - release_tag="v$year.$month.$day" + release_tag="$year.$month.$day" fi - if ! printf '%s\n' "$release_tag" | grep -Eq '^v[0-9]{4}[.][1-9][0-9]?[.][1-9][0-9]?$'; then - echo "Tag must look like v2026.5.20: $release_tag" >&2 + if ! printf '%s\n' "$release_tag" | grep -Eq '^[0-9]{4}[.][1-9][0-9]?[.][1-9][0-9]?$'; then + echo "Tag must look like 2026.5.20: $release_tag" >&2 exit 1 fi - target_version="${release_tag#v}" + target_version="$release_tag" fi echo "event=$GITHUB_EVENT_NAME" @@ -124,7 +129,7 @@ jobs: - name: ♻️ Restore cache id: cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | ~/.gradle/caches/transforms-* @@ -254,7 +259,10 @@ jobs: env: RELEASE_TAG: ${{ steps.plan.outputs.tag }} run: | - version="${RELEASE_TAG#v}" + version="$RELEASE_TAG" + case "$version" in + v*) version="${version#v}" ;; + esac awk -v version="$version" ' $0 ~ "^## \\[" version "\\]" { found = 1; next } found && $0 ~ /^## / { exit } @@ -338,7 +346,7 @@ jobs: - name: 💾 Save cache if: success() && github.event_name != 'pull_request' && steps.cache.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | ~/.gradle/caches/transforms-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 26bd932..6ab3979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,27 @@ ## [Unreleased] +### Plugin Wiring + +- Simplified workflow syntax routing for completion, highlighting, references, and documentation. +- Reduced duplicated workflow run, action cache, and schema handling internals while keeping behavior stable. +- Reorganized plugin internals into fewer `Workflow*` service entry points, with public service Javadocs where humans + might actually read them. +- Added the plugin size refactor plan and documented the release/changelog flow. +- Release automation now uses plain date tags, avoids duplicated Marketplace change-note headings, and groups Dependabot + dependency updates into one weekly PR against `dev`. +- Updated the IntelliJ test platform, JaCoCo, and GitHub Actions cache action to current stable metadata. +- Fixed the README build badge link and refreshed navigation/release docs. +- `.gitea/workflows/*` files now get their own light/dark Gitea-flavored file icon instead of cosplaying GitHub. + ## [2026.5.29] - 2026-05-29 +### Release Polish + +- Marketplace change notes skip the duplicated version heading. +- Future GitHub release tags use plain date versions without a leading `v`. +- Dependabot dependency bumps are grouped into one weekly cross-ecosystem PR against `dev`. + ## [2026.5.23] - 2026-05-23 ### Fresh Start diff --git a/README.md b/README.md index a6da033..fccd47b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ *Your Ultimate Wingman for GitHub Workflows and Actions! 🚀* -![Build](https://github.com/YunaBraska/github-workflow-plugin/workflows/Build/badge.svg) +[![Build](https://github.com/YunaBraska/github-workflow-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/YunaBraska/github-workflow-plugin/actions/workflows/build.yml) [![Version](https://img.shields.io/jetbrains/plugin/v/21396-github-workflow.svg)](https://plugins.jetbrains.com/plugin/21396-github-workflow) [![Downloads](https://img.shields.io/jetbrains/plugin/d/21396-github-workflow.svg)](https://plugins.jetbrains.com/plugin/21396-github-workflow) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/YunaBraska) @@ -79,25 +79,24 @@ Plugin downloads the IDE, bundled plugins, verifier, and test runtime. ## Release Automation -One GitHub Actions workflow runs for branch pushes, PRs, and manual dispatches. It has one job and one cache. Branch and -PR runs do the normal test/package pass. A merge to `main`, or a manual workflow run, prepares the date-based version, -runs the full checks and Plugin Verifier, publishes the plugin ZIP to GitHub Packages, uploads the same ZIP to -JetBrains Marketplace, pushes the release commit and tag, and creates the GitHub release. +One workflow handles tagging, packaging, GitHub Packages, Marketplace publishing, changelog notes, and GitHub releases. +It intentionally uses one job and one cache because the IntelliJ build/test setup can eat roughly 10 GB; repeating that +across jobs is how CI becomes a very expensive space heater. -The workflow prunes old GitHub Actions caches after a successful non-PR run so only the current pipeline cache remains. +Flow: -Required repository secrets: +1. Branch pushes and PRs run tests and build the plugin ZIP. +2. A merge to `main`, or a manual workflow run, switches the same job into release mode. +3. The job computes a plain date tag such as `2026.5.29` and updates `pluginVersion`. +4. It creates the matching `CHANGELOG.md` section from `## [Unreleased]` when needed. +5. It runs `check`, Plugin Verifier, and `buildPlugin`. +6. It publishes the ZIP to GitHub Packages, then uploads the same ZIP to JetBrains Marketplace. +7. Only after publishing succeeds, it pushes the release commit and tag, then creates or updates the GitHub release. +8. A successful non-PR run saves the current cache and prunes older GitHub Actions caches. -* `PUBLISH_TOKEN` - -Optional repository secret: - -* `RELEASE_TOKEN` - lets the workflow push the release commit and tag with a dedicated token. Without it, `GITHUB_TOKEN` - is used. - -Optional repository variable: - -* `MARKETPLACE_CHANNEL` - empty means the default stable Marketplace channel. +`PUBLISH_TOKEN` is required for Marketplace upload. `RELEASE_TOKEN` is optional; without it, the workflow uses +`GITHUB_TOKEN` for the release commit, tag, and GitHub release. `MARKETPLACE_CHANNEL` is optional and empty means the +stable channel. For manual IDE testing, run `./gradlew runIde`. The default target tracks the latest stable IntelliJ IDEA platform that the Gradle tooling can resolve (`platformVersion` in `gradle.properties`). The first run downloads IDE artifacts and can diff --git a/build.gradle b/build.gradle index d643981..106c822 100644 --- a/build.gradle +++ b/build.gradle @@ -238,7 +238,7 @@ tasks.register('generateGitHubDocsData') { } jacoco { - toolVersion = '0.8.13' + toolVersion = '0.8.15' } jacocoTestReport { @@ -261,8 +261,9 @@ intellijPlatform { name = requiredProperty('pluginName') version = requiredProperty('pluginVersion') description = requiredProperty('pluginDescription') - changeNotes = provider { - changelog.renderItem(changelog.getLatest(), Changelog.OutputType.HTML) + changeNotes = providers.gradleProperty('pluginVersion').map { pluginVersion -> + def item = changelog.has(pluginVersion) ? changelog.get(pluginVersion) : changelog.getLatest() + changelog.renderItem(item.withHeader(false).withEmptySections(false), Changelog.OutputType.HTML) } ideaVersion { diff --git a/doc/adr/0011-release-once-publish-zip.md b/doc/adr/0011-release-once-publish-zip.md index bdcb73a..4732d24 100644 --- a/doc/adr/0011-release-once-publish-zip.md +++ b/doc/adr/0011-release-once-publish-zip.md @@ -12,7 +12,8 @@ out. ## Decision -Use one workflow file with one job and one manually managed cache key. +Use one workflow file with one job and one manually managed cache key. IntelliJ test and verifier setup is large enough +that splitting the same release path across jobs duplicates heavyweight downloads and cache state. The same job handles all modes: @@ -20,9 +21,9 @@ The same job handles all modes: - a `main` push that is not the generated release commit executes the release path; - a manual dispatch executes the release path, with optional dry-run support. -The release path prepares the version, runs the full checks and Plugin Verifier, publishes the plugin ZIP to GitHub -Packages, uploads the same ZIP directly to JetBrains Marketplace, pushes the release commit and tag, and creates or -updates the GitHub release. +The release path prepares the version and changelog, runs the full checks and Plugin Verifier, publishes the plugin ZIP +to GitHub Packages, uploads the same ZIP directly to JetBrains Marketplace, then pushes the release commit/tag and +creates or updates the GitHub release. After a successful non-PR run, the job prunes every GitHub Actions cache entry except the current pipeline cache key. diff --git a/doc/navigation.md b/doc/navigation.md index caf8c5d..ad13167 100644 --- a/doc/navigation.md +++ b/doc/navigation.md @@ -9,21 +9,28 @@ The plugin is intentionally plain Java with Gradle wrapper entrypoints. The usef ## Runtime Entry Points -- `CodeCompletion` handles workflow expressions, `uses`, `with`, secrets, shell values, local files, remote action refs, - and GitHub context/default environment completions. -- `HighlightAnnotator` handles editor diagnostics, symbol coloring, quick fixes, action update suggestions, and +- `WorkflowLocation.from(PsiElement)` is the shared PSI/YAML location for workflow keys, paths, files, repositories, + and branches. +- `WorkflowSyntax` owns workflow syntax completions, validation metadata, JSON schema hookup, file icons, and `run` + language injection. +- `WorkflowReferences` owns local PSI references, remote web references, and expression reference targets. +- `GitHubActionCache` is the cache boundary for action/reusable-workflow metadata, cache actions, warning restore, and + startup refresh. +- `WorkflowRun` is the remote workflow-run boundary: dispatch, cancel, rerun, delete, jobs, logs, artifacts, and branch + resolution. +- `WorkflowCompletion` handles workflow expressions, `uses`, `with`, secrets, shell values, local files, remote action refs, + and GitHub context/default environment completions through `WorkflowSyntax`, `WorkflowLocation`, and + `GitHubActionCache`. +- `WorkflowAnnotator` handles editor diagnostics, symbol coloring, quick fixes, action update suggestions, and variable/run output highlighting. -- `ReferenceContributor` handles local PSI references and remote web references. - `WorkflowDocumentationProvider` handles hover and quick documentation. -- `WorkflowRunLanguageInjector` injects shell-like languages into `run` blocks based on `shell`. -- `WorkflowRunConfigurationType` and related workflow-run classes handle workflow dispatch from the IDE Run tool window. -- `GitHubRequestAuthorizations` centralizes GitHub account, optional token-env fallback, and anonymous request ordering. -- `GitHubActionCache`, `RemoteActionProviders`, and `RemoteServerSettings` resolve and cache local/remote action and - reusable workflow metadata. +- `RemoteActionProviders` centralizes GitHub account, enterprise account, optional token-env fallback, anonymous request + ordering, and remote server settings. +- `WorkflowRunConfiguration` handles workflow dispatch from the IDE Run tool window. - `GitHubWorkflowSettingsConfigurable` exposes the plugin settings page for language override, cache review/delete, cache import/export, plugin cache size, and the tiny support button with suspicious amounts of caffeine energy. -- `WorkflowRunLogRenderer` compacts GitHub Actions logs into named blocks with `0001 |` line numbers, `run:` command - lines, ANSI cleanup, and warning/error classification for Run tool-window job consoles. +- `WorkflowRunView.LogRenderer` compacts GitHub Actions logs into named blocks with `0001 |` line numbers, + `run:` command lines, ANSI cleanup, and warning/error classification for Run tool-window job consoles. ## Tests diff --git a/doc/spec/plugin-size-refactor-plan.md b/doc/spec/plugin-size-refactor-plan.md new file mode 100644 index 0000000..3ee9fe9 --- /dev/null +++ b/doc/spec/plugin-size-refactor-plan.md @@ -0,0 +1,63 @@ +# Plugin Size Refactor Plan + +## Goal + +Reduce plugin size and production-code duplication while keeping the current feature set and public behavior covered by +tests. + +This is not a feature rollback. The target is a smaller, calmer implementation: same teeth, less boilerplate jaw. + +## Marketplace Baseline + +| Version | Status | Date | Compatibility Range | Size | Uploaded By | +| --- | --- | --- | --- | --- | --- | +| 2025.0.0 | Approved | 21 Apr 2025 | 251.0+ | 150.58 KB | Yuna Morgenstern | +| 2026.5.23 | Under review | 23 May 2026 | 242.0+ | 620.83 KB | Yuna Morgenstern | + +## Current Local Size Shape + +The 2026 line grew for real reasons: workflow run UI, broader completion/highlighting/validation, remote metadata, cache +controls, and top-20 localization. + +The largest cleanup candidates are: + +- Completion, validation, documentation, highlighting, and references each walk similar workflow structure. +- `WorkflowCompletion` and `WorkflowRunView` are large coordination classes. +- Resource bundles repeat many full strings across all locale files. +- Workflow syntax metadata is split across schemas, generated docs snapshots, hard-coded maps, and local presentation + code. + +## Refactor Direction + +1. Build one immutable `WorkflowModel` from the YAML PSI. +2. Feed completion, validation, references, hover docs, line markers, and highlighting from that model. +3. Move workflow syntax keys, values, descriptions, and validation rules into one data-driven registry. +4. Split workflow run UI into small model, tree state, renderer, toolbar action, and log view classes. +5. Reduce localization duplication by keeping the base bundle authoritative and only overriding translated values. +6. Keep schemas and docs snapshots, but avoid duplicating their meaning in Java maps when generated data can serve it. + +## Expected Reduction + +Realistic target: + +- 25-35% less production Java code. +- 150-250 KB smaller Marketplace artifact if localization and duplicated syntax metadata are cleaned up. +- Final artifact target: 350-450 KB while keeping current behavior. + +Returning to roughly 150 KB is not realistic without dropping current functionality or broad localization. + +## Guardrails + +- Keep current tests green. +- Add regression tests before changing shared completion, validation, highlighting, or workflow-run behavior. +- Prefer public editor/runtime entrypoints over private helper tests. +- Do not change user-facing behavior unless the test or spec says the old behavior was wrong. +- No speculative abstractions. If a shared model does not simplify at least two consumers, delete it. + +## Done Criteria + +- Marketplace artifact size is measured before and after. +- Production Java line count is measured before and after. +- Existing editor and workflow-run tests pass. +- Manual IDE smoke test covers completion, validation, quick fixes, left ruler markers, and workflow run tree. +- Release notes mention user-visible polish only, not internal surgery. diff --git a/gradle.properties b/gradle.properties index 147cb4b..4fe10ae 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ pluginSinceBuild = 242 # Not specifying until-build means it will include all future builds (including unreleased IDE versions, which might impact compatibility later). # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension -platformVersion = 2026.1.2 +platformVersion = 2026.1.3 pluginUrl=https://github.com/YunaBraska/github-workflow-plugin diff --git a/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java new file mode 100644 index 0000000..cc89dd6 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowAnnotator.java @@ -0,0 +1,489 @@ +package com.github.yunabraska.githubworkflow.entry; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowSyntax; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.github.yunabraska.githubworkflow.model.IconRenderer; +import com.github.yunabraska.githubworkflow.model.NodeIcon; +import com.github.yunabraska.githubworkflow.model.SimpleElement; +import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; +import com.intellij.codeInspection.ProblemHighlightType; +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.Annotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.intellij.psi.impl.source.tree.LeafPsiElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.yaml.psi.YAMLKeyValue; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.*; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.deleteElementAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.getFirstChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.replaceAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.simpleTextRange; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.goToDeclarationString; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStep; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getTextElement; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.parseEnvVariables; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.parseOutputVariables; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.toYAMLKeyValue; +import static com.github.yunabraska.githubworkflow.syntax.Action.highLightAction; +import static com.github.yunabraska.githubworkflow.syntax.Action.highlightActionInput; +import static com.github.yunabraska.githubworkflow.syntax.Envs.highLightEnvs; +import static com.github.yunabraska.githubworkflow.syntax.Inputs.highLightInputs; +import static com.github.yunabraska.githubworkflow.syntax.JobContext.highlightJob; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.highLightJobs; +import static com.github.yunabraska.githubworkflow.syntax.Matrix.highlightMatrix; +import static com.github.yunabraska.githubworkflow.syntax.Needs.highlightNeeds; +import static com.github.yunabraska.githubworkflow.syntax.Secrets.highLightSecrets; +import static com.github.yunabraska.githubworkflow.syntax.Steps.highlightSteps; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_TEXT_VARIABLE; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.RELOAD; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_ON; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.isChildOf; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathEndsWith; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathMatches; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowReferences.splitToElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowReferences.toSimpleElements; +import static com.intellij.lang.annotation.HighlightSeverity.INFORMATION; +import static java.util.Optional.ofNullable; + +public class WorkflowAnnotator implements Annotator { + + public static final TextAttributesKey VARIABLE_REFERENCE = TextAttributesKey.createTextAttributesKey( + "GITHUB_WORKFLOW_VARIABLE_REFERENCE", + DefaultLanguageHighlighterColors.CONSTANT + ); + public static final TextAttributesKey DECLARATION = TextAttributesKey.createTextAttributesKey( + "GITHUB_WORKFLOW_DECLARATION", + DefaultLanguageHighlighterColors.STATIC_FIELD + ); + public static final TextAttributesKey RUNNER_VARIABLE = TextAttributesKey.createTextAttributesKey( + "GITHUB_WORKFLOW_RUNNER_VARIABLE", + DefaultLanguageHighlighterColors.GLOBAL_VARIABLE + ); + public static final TextAttributesKey SCALAR_LITERAL = TextAttributesKey.createTextAttributesKey( + "GITHUB_WORKFLOW_SCALAR_LITERAL", + DefaultLanguageHighlighterColors.NUMBER + ); + + @Override + public void annotate(@NotNull final PsiElement psiElement, @NotNull final AnnotationHolder holder) { + annotationTrigger(holder, psiElement).ifPresent(trigger -> trigger + .then(WorkflowAnnotator::processPsiElement) + .then(WorkflowAnnotator::variableElementHandler) + .then(WorkflowAnnotator::highlightVariableReferences) + .then(WorkflowAnnotator::highlightDeclarations) + .then(WorkflowAnnotator::highlightRunOutputs) + .then(WorkflowAnnotator::highlightRunnerVariables) + .then(WorkflowAnnotator::highlightScalarLiterals) + .then(WorkflowAnnotator::validateWorkflowSyntax) + .then((currentHolder, currentElement) -> highlightActionInput(currentHolder, currentElement)) + .then((currentHolder, currentElement) -> highlightNeeds(currentHolder, currentElement))); + } + + private static Optional annotationTrigger(final AnnotationHolder holder, final PsiElement psiElement) { + return psiElement.isValid() + ? Optional.of(new AnnotationTrigger(holder, psiElement)) + : Optional.empty(); + } + + public static void processPsiElement(final AnnotationHolder holder, final PsiElement psiElement) { + toYAMLKeyValue(psiElement).ifPresent(element -> { + switch (element.getKeyText()) { + case FIELD_USES -> highLightAction(holder, element); + case FIELD_OUTPUTS -> outputsHandler(holder, element); + default -> { + // No Action + } + } + }); + } + + private static void highlightRunOutputs(final AnnotationHolder holder, final PsiElement psiElement) { + // SHOW Output Env && Output Variable declaration + Optional.of(psiElement) + .filter(LeafPsiElement.class::isInstance) + .map(LeafPsiElement.class::cast) + .filter(element -> WorkflowPsi.getParent(element, FIELD_RUN).isPresent()) + .ifPresent(element -> Stream.of( + parseEnvVariables(element).stream().map(variable -> withIcon(variable, ICON_ENV)).toList(), + parseOutputVariables(element).stream().map(variable -> withIcon(variable, ICON_TEXT_VARIABLE)).toList() + ).flatMap(Collection::stream).collect(Collectors.groupingBy(SimpleElement::startIndexOffset)).forEach((integer, elements) -> ofNullable(getFirstChild(elements)).ifPresent(lineElement -> holder + .newSilentAnnotation(INFORMATION) + .range(lineElement.range()) + .textAttributes(DECLARATION) + .gutterIconRenderer(new IconRenderer(null, element, lineElement.icon())) + .create() + ))); + } + + private static SimpleElement withIcon(final SimpleElement element, final NodeIcon icon) { + return new SimpleElement(element.key(), element.text(), element.range(), icon); + } + + private static void highlightRunnerVariables(final AnnotationHolder holder, final PsiElement psiElement) { + Optional.of(psiElement) + .filter(LeafPsiElement.class::isInstance) + .map(LeafPsiElement.class::cast) + .filter(element -> getParent(element, FIELD_RUN).isPresent()) + .ifPresent(element -> DEFAULT_VALUE_MAP.get(FIELD_ENVS).get().keySet().forEach(name -> highlightWord(holder, element, name, RUNNER_VARIABLE))); + } + + private static void highlightWord( + final AnnotationHolder holder, + final PsiElement element, + final String word, + final com.intellij.openapi.editor.colors.TextAttributesKey attributes + ) { + final String text = element.getText(); + int index = text.indexOf(word); + while (index >= 0) { + final int end = index + word.length(); + final boolean before = index == 0 || !isIdentifierChar(text.charAt(index - 1)); + final boolean after = end >= text.length() || !isIdentifierChar(text.charAt(end)); + if (before && after) { + holder.newSilentAnnotation(INFORMATION) + .range(new TextRange(element.getTextRange().getStartOffset() + index, element.getTextRange().getStartOffset() + end)) + .textAttributes(attributes) + .create(); + } + index = text.indexOf(word, end); + } + } + + private static void highlightScalarLiterals(final AnnotationHolder holder, final PsiElement psiElement) { + toYAMLKeyValue(psiElement) + .flatMap(WorkflowPsi::getTextElement) + .filter(text -> text.getText().matches("true|false|-?\\d+(?:\\.\\d+)?")) + .ifPresent(text -> holder.newSilentAnnotation(INFORMATION) + .range(text) + .textAttributes(SCALAR_LITERAL) + .create()); + } + + private static void validateWorkflowSyntax(final AnnotationHolder holder, final PsiElement psiElement) { + if (!(psiElement instanceof YAMLKeyValue)) { + return; + } + WorkflowLocation.from(psiElement) + .filter(WorkflowAnnotator::shouldValidateWorkflowSyntax) + .ifPresent(location -> validateWorkflowKeyValue(holder, location.keyValue(), location.path())); + } + + private static boolean shouldValidateWorkflowSyntax(final WorkflowLocation location) { + return location.workflowFile() || isUnitTestWorkflowFile(location.keyValue()); + } + + private static boolean isUnitTestWorkflowFile(final YAMLKeyValue element) { + return ApplicationManager.getApplication().isUnitTestMode() + && WorkflowPsi.getChild(element.getContainingFile(), "runs").isEmpty(); + } + + private static void validateWorkflowKeyValue(final AnnotationHolder holder, final YAMLKeyValue element, final List path) { + WorkflowSyntax.validationKeysForPath(path).ifPresent(keys -> { + validateKnownKey(holder, element, keys.values(), keys.messageKey()); + validateWorkflowPropertyValue(holder, element, path); + }); + } + + private static void validateWorkflowPropertyValue( + final AnnotationHolder holder, + final YAMLKeyValue element, + final List path + ) { + final String key = element.getKeyText(); + if (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) + || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS)) { + validateWorkflowInputPropertyValue(holder, element, path); + } + if (isChildOf(path, FIELD_ON, "workflow_call", FIELD_SECRETS) && "required".equals(key)) { + validateKnownValue(holder, element, WorkflowSyntax.booleanValues(), "inspection.workflow.syntax.unknownTriggerValue"); + } + if (pathMatches(path, FIELD_ON, "*") && "types".equals(key)) { + validateKnownValue(holder, element, WorkflowSyntax.eventActivityTypesFor(path.get(1)), "inspection.workflow.syntax.unknownTriggerValue"); + } + if (pathEndsWith(path, "permissions")) { + validateKnownValue(holder, element, WorkflowSyntax.permissionValuesFor(key), "inspection.workflow.syntax.unknownPermissionValue"); + } + } + + private static void validateWorkflowInputPropertyValue( + final AnnotationHolder holder, + final YAMLKeyValue element, + final List path + ) { + if ("type".equals(element.getKeyText())) { + validateKnownValue(holder, element, WorkflowSyntax.workflowInputTypesFor(path.get(1)), "inspection.workflow.syntax.unknownTriggerValue"); + } + if ("required".equals(element.getKeyText())) { + validateKnownValue(holder, element, WorkflowSyntax.booleanValues(), "inspection.workflow.syntax.unknownTriggerValue"); + } + } + + private static void validateKnownKey( + final AnnotationHolder holder, + final YAMLKeyValue element, + final Map allowed, + final String messageKey + ) { + if (allowed.containsKey(element.getKeyText()) || element.getKeyText().isBlank()) { + return; + } + final TextRange range = Optional.ofNullable(element.getKey()) + .map(PsiElement::getTextRange) + .orElseGet(element::getTextRange); + createKnownAnnotation(holder, element, range, GitHubWorkflowBundle.message(messageKey, element.getKeyText()), allowed); + } + + private static void validateKnownValue( + final AnnotationHolder holder, + final YAMLKeyValue element, + final Map allowed, + final String messageKey + ) { + final String value = WorkflowPsi.getText(element).orElse(""); + if (allowed.isEmpty() + || value.isBlank() + || value.startsWith("${{") + || !value.matches("[A-Za-z0-9_-]+") + || allowed.containsKey(value)) { + return; + } + WorkflowPsi.getTextElement(element).ifPresent(valueElement -> { + final TextRange range = valueElement.getTextRange(); + createKnownAnnotation(holder, element, range, GitHubWorkflowBundle.message(messageKey, value), allowed); + }); + } + + private static void createKnownAnnotation( + final AnnotationHolder holder, + final YAMLKeyValue element, + final TextRange range, + final String message, + final Map allowed + ) { + final List fixes = new ArrayList<>(); + fixes.add(new SyntaxAnnotation(message, null, HighlightSeverity.WEAK_WARNING, ProblemHighlightType.WEAK_WARNING, null)); + allowed.keySet().stream() + .map(candidate -> new SyntaxAnnotation( + GitHubWorkflowBundle.message("inspection.replace.with", candidate), + RELOAD, + HighlightSeverity.WEAK_WARNING, + ProblemHighlightType.WEAK_WARNING, + replaceAction(range, candidate) + )) + .forEach(fixes::add); + SyntaxAnnotation.createAnnotation(element, range, holder, fixes); + } + + private static void outputsHandler(final AnnotationHolder holder, final PsiElement psiElement) { + getParentJob(psiElement).ifPresent(job -> { + final List outputs = WorkflowPsi.getChildren(psiElement).stream().toList(); + final String workflowText = WorkflowPsi.getChild(psiElement.getContainingFile(), FIELD_JOBS).map(PsiElement::getText).orElse(""); + final List workflowOutputs = WorkflowPsi.getChild(psiElement.getContainingFile(), FIELD_ON) + .map(on -> getAllElements(on, FIELD_OUTPUTS)) + .map(list -> list.stream().flatMap(keyValue -> WorkflowPsi.getChildren(keyValue).stream().map(output -> getText(output, "value").orElse(""))).toList()) + .orElseGet(Collections::emptyList); + outputs.stream().filter(output -> { + final String outputKey = output.getKeyText(); + final String reusableOutputReference = FIELD_JOBS + "." + job.getKeyText() + "." + FIELD_OUTPUTS + "." + outputKey; + final String needsOutputReference = FIELD_NEEDS + "." + job.getKeyText() + "." + FIELD_OUTPUTS + "." + outputKey; + return workflowOutputs.stream().noneMatch(value -> containsOutputReference(value, reusableOutputReference)) + && !containsOutputReference(workflowText, needsOutputReference); + }).forEach(output -> new SyntaxAnnotation( + GitHubWorkflowBundle.message("inspection.output.unused", output.getKeyText()), + SUPPRESS_ON, + HighlightSeverity.WEAK_WARNING, + ProblemHighlightType.LIKE_UNUSED_SYMBOL, + deleteElementAction(output.getTextRange()), + true + ).createAnnotation(output, output.getTextRange(), holder)); + + }); + } + + private static boolean containsOutputReference(final String text, final String reference) { + int index = ofNullable(text).orElse("").indexOf(reference); + while (index >= 0) { + final int end = index + reference.length(); + if (end >= text.length() || !isIdentifierChar(text.charAt(end))) { + return true; + } + index = text.indexOf(reference, end); + } + return false; + } + + @NotNull + public static Predicate isElementWithVariables(final YAMLKeyValue parentIf) { + return element -> ofNullable(parentIf) + .or(() -> getParent(element, FIELD_RUN)) + .or(() -> getParent(element, FIELD_ID)) + .or(() -> getParent(element, "name")) + .or(() -> getParent(element, "run-name")) + .or(() -> getParent(element, "runs-on")) + .or(() -> getParent(element, "concurrency")) + .or(() -> getParent(element, "group").filter(group -> getParent(group, "concurrency").isPresent())) + .or(() -> getParent(element, "default").filter(defaultValue -> getParent(defaultValue, FIELD_INPUTS).isPresent())) + .or(() -> getParent(element, "credentials")) + .or(() -> getParent(element, "environment")) + .or(() -> getParent(element, "fail-fast").filter(failFast -> getParent(failFast, FIELD_STRATEGY).isPresent())) + .or(() -> getParent(element, "max-parallel").filter(maxParallel -> getParent(maxParallel, FIELD_STRATEGY).isPresent())) + .or(() -> getParent(element, "shell").filter(shell -> getParent(shell, "defaults").isPresent())) + .or(() -> getParent(element, "container").filter(container -> getParent(container, "jobs").isPresent())) + .or(() -> getParent(element, "url").filter(url -> getParent(url, "environment").isPresent())) + .or(() -> getParent(element, "timeout-minutes")) + .or(() -> getParent(element, "continue-on-error")) + .or(() -> getParent(element, "working-directory")) + .or(() -> getParent(element, "image").filter(image -> getParent(image, "container").isPresent() || getParent(image, "services").isPresent())) + .or(() -> getParent(element, "value").isPresent() ? getParent(element, FIELD_OUTPUTS) : Optional.empty()) + .or(() -> getParent(element, FIELD_WITH)) + .or(() -> getParent(element, FIELD_ENVS)) + .or(() -> getParent(element, FIELD_OUTPUTS)) + .isPresent(); + } + + private static void variableElementHandler(final AnnotationHolder holder, final PsiElement psiElement) { + final Optional parentIf = getParent(psiElement, FIELD_IF); + Optional.of(psiElement) + .filter(LeafPsiElement.class::isInstance) + .map(LeafPsiElement.class::cast) + .filter(isElementWithVariables(parentIf.orElse(null))) + .ifPresent(element -> toSimpleElements(element).forEach(simpleElement -> { + final SimpleElement[] parts = splitToElements(simpleElement); + switch (parts.length > 0 ? parts[0].text() : "N/A") { + case FIELD_INPUTS -> highLightInputs(holder, element, parts); + case FIELD_SECRETS -> + highLightSecrets(holder, psiElement, element, simpleElement, parts, parentIf.orElse(null)); + case FIELD_ENVS -> highLightEnvs(holder, element, parts); + case FIELD_GITHUB -> highlightContext(holder, element, parts, FIELD_GITHUB, -1); + case FIELD_GITEA -> highlightContext(holder, element, parts, FIELD_GITEA, -1); + case FIELD_JOB -> highlightJob(holder, element, parts); + case FIELD_RUNNER -> highlightContext(holder, element, parts, FIELD_RUNNER, 2); + case FIELD_MATRIX -> highlightMatrix(holder, element, parts); + case FIELD_STRATEGY -> highlightContext(holder, element, parts, FIELD_STRATEGY, 2); + case FIELD_STEPS -> highlightSteps(holder, element, parts); + case FIELD_JOBS -> highLightJobs(holder, element, parts); + case FIELD_NEEDS -> highlightNeeds(holder, element, parts); + default -> { + // ignored + } + } + }) + ); + } + + private static void highlightContext( + final AnnotationHolder holder, + final LeafPsiElement element, + final SimpleElement[] parts, + final String field, + final int maxParts + ) { + ifEnoughItems(holder, element, parts, 2, maxParts, item -> isDefinedItem0(element, holder, item, DEFAULT_VALUE_MAP.get(field).get().keySet())); + } + + private static void highlightVariableReferences(final AnnotationHolder holder, final PsiElement psiElement) { + Optional.of(psiElement) + .filter(WorkflowPsi::isTextElement) + .ifPresent(element -> { + toSimpleElements(element).stream() + .flatMap(source -> Stream.of(splitToElements(source))) + .forEach(segment -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(simpleTextRange(element, segment)) + .textAttributes(VARIABLE_REFERENCE) + .create()); + WorkflowReferences.resolve(element).forEach(target -> { + final String tooltip = goToDeclarationString(); + holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(simpleTextRange(element, target.segment())) + .textAttributes(VARIABLE_REFERENCE) + .create(); + holder.newAnnotation(HighlightSeverity.INFORMATION, tooltip) + .range(simpleTextRange(element, target.segment())) + .textAttributes(DefaultLanguageHighlighterColors.HIGHLIGHTED_REFERENCE) + .tooltip(tooltip) + .create(); + }); + }); + } + + private static void highlightDeclarations(final AnnotationHolder holder, final PsiElement psiElement) { + toYAMLKeyValue(psiElement).ifPresent(element -> { + highlightJobDeclaration(holder, element); + highlightStepDeclaration(holder, element); + }); + } + + private static void highlightJobDeclaration(final AnnotationHolder holder, final YAMLKeyValue element) { + getParent(element, FIELD_JOBS) + .filter(jobs -> isDirectChildOf(element, jobs)) + .flatMap(job -> ofNullable(element.getKey())) + .ifPresent(key -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(key) + .textAttributes(DECLARATION) + .create()); + } + + private static boolean isDirectChildOf(final YAMLKeyValue child, final YAMLKeyValue parent) { + PsiElement current = child.getParent(); + while (current != null && current != parent) { + if (current instanceof YAMLKeyValue) { + return false; + } + current = current.getParent(); + } + return current == parent; + } + + private static void highlightStepDeclaration(final AnnotationHolder holder, final YAMLKeyValue element) { + if (FIELD_ID.equals(element.getKeyText()) && getParentStep(element).isPresent()) { + getTextElement(element).ifPresent(text -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(text) + .textAttributes(DECLARATION) + .create()); + } + } + + private static boolean isIdentifierChar(final char character) { + return WorkflowReferences.isIdentifierChar(character); + } + + private record AnnotationTrigger(AnnotationHolder holder, PsiElement psiElement) { + + private AnnotationTrigger then(final BiConsumer step) { + step.accept(holder, psiElement); + return this; + } + } + +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/CodeCompletion.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java similarity index 61% rename from src/main/java/com/github/yunabraska/githubworkflow/services/CodeCompletion.java rename to src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java index cb2604d..d052f6f 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/CodeCompletion.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowCompletion.java @@ -1,26 +1,46 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.entry; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.logic.Steps; +import com.github.yunabraska.githubworkflow.syntax.WorkflowSyntax; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.intellij.codeInsight.AutoPopupController; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.github.yunabraska.githubworkflow.syntax.Steps; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.github.yunabraska.githubworkflow.model.NodeIcon; import com.github.yunabraska.githubworkflow.model.SimpleElement; +import com.intellij.codeInsight.completion.CompletionConfidence; import com.intellij.codeInsight.completion.CompletionContributor; import com.intellij.codeInsight.completion.CompletionParameters; import com.intellij.codeInsight.completion.CompletionProvider; import com.intellij.codeInsight.completion.CompletionResultSet; import com.intellij.codeInsight.completion.CompletionType; import com.intellij.codeInsight.completion.impl.CamelHumpMatcher; +import com.intellij.codeInsight.editorActions.TypedHandlerDelegate; +import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegateAdapter; import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.openapi.actionSystem.DataContext; import com.intellij.lang.injection.InjectedLanguageManager; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Editor; import com.intellij.openapi.project.Project; import com.intellij.openapi.roots.ProjectFileIndex; import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiDocumentManager; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.util.TextRange; import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; import com.intellij.psi.PsiLanguageInjectionHost; import com.intellij.util.ProcessingContext; +import com.intellij.util.ThreeState; import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.psi.YAMLKeyValue; import org.jetbrains.yaml.psi.YAMLSequenceItem; @@ -40,49 +60,53 @@ import java.util.regex.Pattern; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.*; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getCaretBracketItem; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getStartIndex; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.isActionFile; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.isWorkflowFile; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.toLookupElement; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildSteps; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStep; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getProject; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStepOrJob; -import static com.github.yunabraska.githubworkflow.logic.Envs.listEnvs; -import static com.github.yunabraska.githubworkflow.logic.GitHub.codeCompletionGitea; -import static com.github.yunabraska.githubworkflow.logic.GitHub.codeCompletionGithub; -import static com.github.yunabraska.githubworkflow.logic.Inputs.listInputs; -import static com.github.yunabraska.githubworkflow.logic.JobContext.codeCompletionJob; -import static com.github.yunabraska.githubworkflow.logic.Jobs.codeCompletionJobs; -import static com.github.yunabraska.githubworkflow.logic.Jobs.listJobs; -import static com.github.yunabraska.githubworkflow.logic.Matrix.listMatrix; -import static com.github.yunabraska.githubworkflow.logic.Needs.codeCompletionNeeds; -import static com.github.yunabraska.githubworkflow.logic.Needs.codeCompletionPreviousJobs; -import static com.github.yunabraska.githubworkflow.logic.Needs.listJobNeeds; -import static com.github.yunabraska.githubworkflow.logic.Runner.codeCompletionRunner; -import static com.github.yunabraska.githubworkflow.logic.Secrets.listSecrets; -import static com.github.yunabraska.githubworkflow.logic.Steps.codeCompletionSteps; -import static com.github.yunabraska.githubworkflow.logic.Steps.listSteps; -import static com.github.yunabraska.githubworkflow.logic.Strategy.codeCompletionStrategy; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.*; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.getCaretBracketItem; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.getStartIndex; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.getWorkflowFile; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.isActionFile; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.isWorkflowFile; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.toLookupElement; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildSteps; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStep; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getProject; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStepOrJob; +import static com.github.yunabraska.githubworkflow.syntax.Envs.listEnvs; +import static com.github.yunabraska.githubworkflow.syntax.Inputs.listInputs; +import static com.github.yunabraska.githubworkflow.syntax.JobContext.codeCompletionJob; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.codeCompletionJobs; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.listJobs; +import static com.github.yunabraska.githubworkflow.syntax.Matrix.listMatrix; +import static com.github.yunabraska.githubworkflow.syntax.Needs.codeCompletionNeeds; +import static com.github.yunabraska.githubworkflow.syntax.Needs.codeCompletionPreviousJobs; +import static com.github.yunabraska.githubworkflow.syntax.Needs.listJobNeeds; +import static com.github.yunabraska.githubworkflow.syntax.Secrets.listSecrets; +import static com.github.yunabraska.githubworkflow.syntax.Steps.codeCompletionSteps; +import static com.github.yunabraska.githubworkflow.syntax.Steps.listSteps; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_JOB; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_NODE; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_OUTPUT; +import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_RUNNER; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemOf; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.isChildOf; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.currentLineKey; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.isValueCompletion; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.keyContextAt; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathEndsWith; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathMatches; import static java.util.Collections.singletonList; import static java.util.Optional.ofNullable; -public class CodeCompletion extends CompletionContributor { +public class WorkflowCompletion extends CompletionContributor { private static final Pattern REMOTE_USES_REF_PATTERN = Pattern.compile(".*\\buses\\s*:\\s*['\"]?([^\\s'\"#]+)@([^\\s'\"]*)$"); private static final Pattern REMOTE_USES_TARGET_PATTERN = Pattern.compile(".*\\buses\\s*:\\s*['\"]?([^\\s'\"#@]*)$"); - public CodeCompletion() { + public WorkflowCompletion() { extend(CompletionType.BASIC, PlatformPatterns.psiElement(), completionProvider()); } @@ -102,93 +126,181 @@ public void addCompletions( @NotNull final ProcessingContext processingContext, @NotNull final CompletionResultSet resultSet ) { - final CompletionPsi completionPsi = completionPsi(parameters); - final PsiElement position = completionPsi.position(); - getWorkflowFile(position).ifPresent(file -> { - final int offset = completionPsi.offset(); - final String[] prefix = new String[]{""}; - final Optional caretBracketItem = offset < 1 ? Optional.of(prefix) : getCaretBracketItem(position, offset, prefix); - final Optional yamlValueCompletion = workflowValueCompletion(completionPsi); - if (yamlValueCompletion.isPresent()) { - final StructureCompletion completion = yamlValueCompletion.get(); - addLookupElements( - resultSet.withPrefixMatcher(getDefaultPrefix(completionPsi)), - completion.items(), - ICON_NODE, - completion.suffix() - ); - return; - } - caretBracketItem.ifPresent(cbi -> addCodeCompletionItems(resultSet, cbi, position, prefix)); - - //ACTIONS && WORKFLOWS - if (caretBracketItem.isEmpty()) { - if (isCompletingRunEnvironmentVariable(completionPsi)) { - // AUTO COMPLETE DEFAULT RUNNER ENVIRONMENT VARIABLES - final Map defaults = ofNullable(DEFAULT_VALUE_MAP.get(FIELD_ENVS)).map(Supplier::get).orElseGet(Collections::emptyMap); - addLookupElements(resultSet.withPrefixMatcher(prefix[0]), defaults, NodeIcon.ICON_ENV, Character.MIN_VALUE); - } else if (isInsideExecutableRunField(position)) { - return; - } else if (isCompletingNeedsField(parameters, position)) { - //[jobs.job_name.needs] list previous jobs - Optional.of(codeCompletionPreviousJobs(position)).filter(cil -> !cil.isEmpty()) - .map(CodeCompletion::toLookupItems) - .ifPresent(lookupElements -> addElementsWithPrefix(resultSet, getDefaultPrefix(parameters), lookupElements)); - } else if (isCompletingUsesField(parameters, position)) { - final Optional remoteUsesRef = remoteUsesRef(parameters); - if (remoteUsesRef.isPresent()) { - final RemoteUsesRef ref = remoteUsesRef.get(); - addLookupElements( - resultSet.withPrefixMatcher(ref.prefix()), - knownRemoteRefs(position, ref.usesBase()), - NodeIcon.ICON_NODE, - Character.MIN_VALUE - ); - return; - } - addLookupElements( - resultSet.withPrefixMatcher(getDefaultPrefix(parameters)), - callableUsesCompletions(position, remoteUsesTargetPrefix(parameters).orElse("")), - NodeIcon.ICON_NODE, - Character.MIN_VALUE - ); - } else if (isCompletingShellField(parameters, position)) { - addLookupElements( - resultSet.withPrefixMatcher(getDefaultPrefix(parameters)), - shells(), - NodeIcon.ICON_NODE, - Character.MIN_VALUE - ); - } else { - final Optional structureCompletion = workflowStructureCompletion(completionPsi); - if (structureCompletion.isPresent()) { - final StructureCompletion completion = structureCompletion.get(); - addLookupElements( - resultSet.withPrefixMatcher(getDefaultPrefix(completionPsi)), - completion.items(), - ICON_NODE, - completion.suffix() - ); - return; - } - if (isCompletingCallableSecrets(position)) { - currentCallableAction(parameters, position) - .map(GitHubAction::freshSecrets) - .ifPresent(map -> addLookupElements(resultSet.withPrefixMatcher(getDefaultPrefix(parameters)), map, NodeIcon.ICON_SECRET_WORKFLOW, ':')); - } else { - //USES COMPLETION [jobs.job_id.steps.step_id:with] - final Optional> withCompletion = currentCallableAction(parameters, position) - .filter(GitHubAction::isResolved) - .map(GitHubAction::freshInputs); - withCompletion.ifPresent(map -> addLookupElements(resultSet.withPrefixMatcher(getDefaultPrefix(parameters)), map, NodeIcon.ICON_INPUT, ':')); - } - } - } - }); + completionTrigger(parameters, resultSet).ifPresent(WorkflowCompletion::complete); } }; } + private static Optional completionTrigger(final CompletionParameters parameters, final CompletionResultSet resultSet) { + final CompletionPsi completionPsi = completionPsi(parameters); + final PsiElement position = completionPsi.position(); + if (getWorkflowFile(position).isEmpty()) { + return Optional.empty(); + } + final String[] prefix = new String[]{""}; + final Optional caretBracketItem = completionPsi.offset() < 1 + ? Optional.of(prefix) + : getCaretBracketItem(position, completionPsi.offset(), prefix); + return Optional.of(new CompletionTrigger( + parameters, + resultSet, + completionPsi, + position, + caretBracketItem, + prefix[0] + )); + } + + private static void complete(final CompletionTrigger trigger) { + if (completeWorkflowValue(trigger) + || completeExpressionPath(trigger) + || completeRunEnvironment(trigger) + || stopInsideExecutableRun(trigger) + || completeNeeds(trigger) + || completeUses(trigger) + || completeShell(trigger) + || completeWorkflowStructure(trigger) + || completeCallableSecrets(trigger)) { + return; + } + completeCallableInputs(trigger); + } + + private static boolean completeWorkflowValue(final CompletionTrigger trigger) { + return workflowValueCompletion(trigger.completionPsi()) + .map(completion -> addStructureCompletion(trigger, completion, getDefaultPrefix(trigger.completionPsi()))) + .orElse(false); + } + + private static boolean completeExpressionPath(final CompletionTrigger trigger) { + return trigger.caretBracketItem() + .map(cbi -> { + addWorkflowCompletionItems(trigger.resultSet(), cbi, trigger.position(), trigger.prefix()); + return true; + }) + .orElse(false); + } + + private static boolean completeRunEnvironment(final CompletionTrigger trigger) { + if (!isCompletingRunEnvironmentVariable(trigger.completionPsi())) { + return false; + } + final Map defaults = ofNullable(DEFAULT_VALUE_MAP.get(FIELD_ENVS)) + .map(Supplier::get) + .orElseGet(Collections::emptyMap); + addLookupElements( + trigger.resultSet().withPrefixMatcher(trigger.prefix()), + defaults, + NodeIcon.ICON_ENV, + Character.MIN_VALUE + ); + return true; + } + + private static boolean stopInsideExecutableRun(final CompletionTrigger trigger) { + return isInsideExecutableRunField(trigger.position()); + } + + private static boolean completeNeeds(final CompletionTrigger trigger) { + if (!isCompletingNeedsField(trigger.parameters(), trigger.position())) { + return false; + } + Optional.of(codeCompletionPreviousJobs(trigger.position())) + .filter(cil -> !cil.isEmpty()) + .map(WorkflowCompletion::toLookupItems) + .ifPresent(lookupElements -> addElementsWithPrefix( + trigger.resultSet(), + getDefaultPrefix(trigger.parameters()), + lookupElements + )); + return true; + } + + private static boolean completeUses(final CompletionTrigger trigger) { + if (!isCompletingUsesField(trigger.parameters(), trigger.position())) { + return false; + } + final Optional remoteUsesRef = remoteUsesRef(trigger.parameters()); + if (remoteUsesRef.isPresent()) { + final RemoteUsesRef ref = remoteUsesRef.get(); + addLookupElements( + trigger.resultSet().withPrefixMatcher(ref.prefix()), + knownRemoteRefs(trigger.position(), ref.usesBase()), + NodeIcon.ICON_NODE, + Character.MIN_VALUE + ); + return true; + } + addLookupElements( + trigger.resultSet().withPrefixMatcher(getDefaultPrefix(trigger.parameters())), + callableUsesCompletions(trigger.position(), remoteUsesTargetPrefix(trigger.parameters()).orElse("")), + NodeIcon.ICON_NODE, + Character.MIN_VALUE + ); + return true; + } + + private static boolean completeShell(final CompletionTrigger trigger) { + if (!isCompletingShellField(trigger.parameters(), trigger.position())) { + return false; + } + addLookupElements( + trigger.resultSet().withPrefixMatcher(getDefaultPrefix(trigger.parameters())), + SHELLS, + NodeIcon.ICON_NODE, + Character.MIN_VALUE + ); + return true; + } + + private static boolean completeWorkflowStructure(final CompletionTrigger trigger) { + return workflowStructureCompletion(trigger.completionPsi()) + .map(completion -> addStructureCompletion(trigger, completion, getDefaultPrefix(trigger.completionPsi()))) + .orElse(false); + } + + private static boolean completeCallableSecrets(final CompletionTrigger trigger) { + if (!isCompletingCallableSecrets(trigger.position())) { + return false; + } + currentCallableAction(trigger.parameters(), trigger.position()) + .map(GitHubAction::freshSecrets) + .ifPresent(map -> addLookupElements( + trigger.resultSet().withPrefixMatcher(getDefaultPrefix(trigger.parameters())), + map, + NodeIcon.ICON_SECRET_WORKFLOW, + ':' + )); + return true; + } + + private static boolean completeCallableInputs(final CompletionTrigger trigger) { + currentCallableAction(trigger.parameters(), trigger.position()) + .filter(GitHubAction::isResolved) + .map(GitHubAction::freshInputs) + .ifPresent(map -> addLookupElements( + trigger.resultSet().withPrefixMatcher(getDefaultPrefix(trigger.parameters())), + map, + NodeIcon.ICON_INPUT, + ':' + )); + return true; + } + + private static boolean addStructureCompletion( + final CompletionTrigger trigger, + final StructureCompletion completion, + final String prefix + ) { + addLookupElements( + trigger.resultSet().withPrefixMatcher(prefix), + completion.items(), + ICON_NODE, + completion.suffix() + ); + return true; + } + private static CompletionPsi completionPsi(final CompletionParameters parameters) { final PsiElement position = parameters.getPosition(); final InjectedLanguageManager injectionManager = InjectedLanguageManager.getInstance(position.getProject()); @@ -238,90 +350,26 @@ private static boolean isInsideExecutableRunField(final PsiElement position) { } private static Optional workflowStructureCompletion(final CompletionPsi completionPsi) { - final Optional context = yamlKeyContext(completionPsi); - if (context.isEmpty() || isYamlValueCompletion(context.get().currentLine())) { + final Optional context = keyContextAt(completionPsi.position(), completionPsi.offset()); + if (context.isEmpty() || isValueCompletion(context.get().currentLine())) { return Optional.empty(); } final List path = context.get().path(); - if (path.isEmpty()) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.topLevelKeys(), ':')); - } - if (pathEndsWith(path, FIELD_ON)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.eventKeys(), ':')); - } - if (pathEndsWith(path, FIELD_ON, "workflow_dispatch")) { - return Optional.of(new StructureCompletion(workflowDispatchTriggerKeys(), ':')); - } - if (pathEndsWith(path, FIELD_ON, "workflow_call")) { - return Optional.of(new StructureCompletion(workflowCallTriggerKeys(), ':')); - } - if (pathEndsWith(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) - || pathEndsWith(path, FIELD_ON, "workflow_call", FIELD_INPUTS)) { - return Optional.empty(); - } - if (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) - || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.workflowInputPropertyKeys(), ':')); - } - if (pathEndsWith(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS)) { - return Optional.empty(); - } - if (isChildOf(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.workflowOutputPropertyKeys(), ':')); - } - if (isChildOf(path, FIELD_ON, "workflow_call", FIELD_SECRETS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.workflowSecretPropertyKeys(), ':')); - } - if (pathMatches(path, FIELD_ON, "*")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.eventFilterKeysFor(path.get(path.size() - 1)), ':')); + final Optional> keys = WorkflowSyntax.completionKeysForPath(path); + if (keys.isPresent()) { + return Optional.of(new StructureCompletion(keys.get(), ':')); } if (pathMatches(path, FIELD_ON, "*", "types")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); } if (pathMatches(path, FIELD_ON, "*", "*")) { return workflowEventFilterValueCompletion(completionPsi, context.get()); } - if (pathEndsWith(path, "permissions")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.permissionScopes(), ':')); - } - if (pathMatches(path, "defaults", FIELD_RUN) || pathMatches(path, FIELD_JOBS, "*", "defaults", FIELD_RUN)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.defaultsRunKeys(), ':')); - } - if (pathMatches(path, "concurrency") || pathMatches(path, FIELD_JOBS, "*", "concurrency")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.concurrencyKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", "environment")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.environmentKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.jobKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_STRATEGY)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.strategyKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_STRATEGY, FIELD_MATRIX)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.matrixKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", "container")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.containerKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", "container", "credentials")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.credentialsKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.serviceKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*", "credentials")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.credentialsKeys(), ':')); - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_STEPS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.stepKeys(), ':')); - } return Optional.empty(); } private static Optional workflowValueCompletion(final CompletionPsi completionPsi) { - final Optional context = yamlKeyContext(completionPsi); + final Optional context = keyContextAt(completionPsi.position(), completionPsi.offset()); if (context.isEmpty()) { return Optional.empty(); } @@ -339,53 +387,52 @@ private static Optional workflowValueCompletion(final Compl return eventFilterValueCompletion; } if ("runs-on".equals(currentKey)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.runnerLabels(), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.runnerLabels(), Character.MIN_VALUE)); } if ("permissions".equals(currentKey)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.permissionShorthandValues(), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.permissionShorthandValues(), Character.MIN_VALUE)); } if ("types".equals(currentKey) && pathMatches(path, FIELD_ON, "*")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.eventActivityTypesFor(path.get(1)), Character.MIN_VALUE)); } - if ("type".equals(currentKey) && isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.workflowInputTypes(), Character.MIN_VALUE)); - } - if ("type".equals(currentKey) && isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.reusableWorkflowInputTypes(), Character.MIN_VALUE)); + if ("type".equals(currentKey) + && (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) + || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS))) { + return Optional.of(new StructureCompletion(WorkflowSyntax.workflowInputTypesFor(path.get(1)), Character.MIN_VALUE)); } if (pathEndsWith(path, "permissions")) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.permissionValuesFor(currentKey), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.permissionValuesFor(currentKey), Character.MIN_VALUE)); } if ("required".equals(currentKey) || "continue-on-error".equals(currentKey) || "fail-fast".equals(currentKey) || "cancel-in-progress".equals(currentKey)) { - return Optional.of(new StructureCompletion(WorkflowSyntaxSchema.booleanValues(), Character.MIN_VALUE)); + return Optional.of(new StructureCompletion(WorkflowSyntax.booleanValues(), Character.MIN_VALUE)); } return Optional.empty(); } - private static Optional workflowEventFilterValueCompletion(final CompletionPsi completionPsi, final YamlKeyContext context) { + private static Optional workflowEventFilterValueCompletion(final CompletionPsi completionPsi, final WorkflowLocation.KeyContext context) { return eventFilterContext(context) .map(eventFilter -> eventFilterValueCompletions(completionPsi.position(), eventFilter.event(), eventFilter.filter())) .filter(values -> !values.isEmpty()) .map(values -> new StructureCompletion(values, Character.MIN_VALUE)); } - private static Optional eventFilterContext(final YamlKeyContext context) { + private static Optional eventFilterContext(final WorkflowLocation.KeyContext context) { final List path = context.path(); final Optional currentKey = currentLineKey(context.currentLine()); if (currentKey.isPresent() && pathMatches(path, FIELD_ON, "*")) { final String event = path.get(1); final String filter = currentKey.get(); - return WorkflowSyntaxSchema.eventFilterKeysFor(event).containsKey(filter) + return WorkflowSyntax.eventFilterKeysFor(event).containsKey(filter) ? Optional.of(new EventFilterContext(event, filter)) : Optional.empty(); } if (pathMatches(path, FIELD_ON, "*", "*")) { final String event = path.get(1); final String filter = path.get(2); - return WorkflowSyntaxSchema.eventFilterKeysFor(event).containsKey(filter) + return WorkflowSyntax.eventFilterKeysFor(event).containsKey(filter) ? Optional.of(new EventFilterContext(event, filter)) : Optional.empty(); } @@ -394,7 +441,7 @@ private static Optional eventFilterContext(final YamlKeyCont private static Map eventFilterValueCompletions(final PsiElement position, final String event, final String filter) { return switch (filter) { - case "types" -> WorkflowSyntaxSchema.eventActivityTypesFor(event); + case "types" -> WorkflowSyntax.eventActivityTypesFor(event); case "branches", "branches-ignore" -> localGitRefs(position, "heads", "completion.workflow.eventFilter.branches"); case "tags", "tags-ignore" -> localGitRefs(position, "tags", "completion.workflow.eventFilter.tags"); case "paths", "paths-ignore" -> localProjectPaths(position); @@ -402,108 +449,6 @@ private static Map eventFilterValueCompletions(final PsiElement }; } - private static Optional yamlKeyContext(final CompletionPsi completionPsi) { - final String wholeText = completionPsi.position().getContainingFile().getText(); - final int offset = boundedOffset(wholeText, completionPsi.offset()); - final int lineStart = currentLineStart(wholeText, offset); - final String currentLine = lineBeforeCaret(wholeText, offset); - final int currentIndent = leadingSpaces(currentLine); - final List stack = new ArrayList<>(); - wholeText.substring(0, Math.min(lineStart, wholeText.length())).lines().forEach(raw -> { - final String content = raw.trim(); - if (!content.isBlank() && !content.startsWith("#")) { - final Optional key = yamlKey(content); - key.ifPresent(value -> { - final int indent = leadingSpaces(raw); - while (!stack.isEmpty() && stack.get(stack.size() - 1).indent() >= indent) { - stack.remove(stack.size() - 1); - } - stack.add(new YamlAncestor(indent, value)); - }); - } - }); - while (!stack.isEmpty() && stack.get(stack.size() - 1).indent() >= currentIndent) { - stack.remove(stack.size() - 1); - } - return Optional.of(new YamlKeyContext(stack.stream().map(YamlAncestor::key).toList(), currentLine)); - } - - private static Optional currentLineKey(final String currentLine) { - return yamlKey(currentLine.trim()); - } - - private static Optional yamlKey(final String content) { - final String normalized = content.startsWith("- ") ? content.substring(2).trim() : content; - final int separator = normalized.indexOf(':'); - if (separator <= 0) { - return Optional.empty(); - } - return Optional.of(stripYamlKeyQuotes(normalized.substring(0, separator).trim())) - .filter(key -> !key.isBlank()); - } - - private static boolean isYamlValueCompletion(final String currentLine) { - return currentLine.replace("IntellijIdeaRulezzz", "").matches("\\s*[^:#]+:\\s*.*"); - } - - private static int leadingSpaces(final String value) { - int result = 0; - while (result < value.length() && value.charAt(result) == ' ') { - result++; - } - return result; - } - - private static String stripYamlKeyQuotes(final String value) { - if (value.length() >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) { - return value.substring(1, value.length() - 1); - } - return value; - } - - private static boolean pathEndsWith(final List path, final String... expected) { - if (path.size() < expected.length) { - return false; - } - final int offset = path.size() - expected.length; - for (int index = 0; index < expected.length; index++) { - if (!expected[index].equals(path.get(offset + index))) { - return false; - } - } - return true; - } - - private static boolean isChildOf(final List path, final String... expectedParent) { - return path.size() == expectedParent.length + 1 && pathEndsWith(path.subList(0, path.size() - 1), expectedParent); - } - - private static boolean pathMatches(final List path, final String... pattern) { - if (path.size() != pattern.length) { - return false; - } - for (int index = 0; index < pattern.length; index++) { - if (!"*".equals(pattern[index]) && !pattern[index].equals(path.get(index))) { - return false; - } - } - return true; - } - - private static Map workflowDispatchTriggerKeys() { - final Map result = new LinkedHashMap<>(); - result.put(FIELD_INPUTS, GitHubWorkflowBundle.message("completion.context.inputs")); - return result; - } - - private static Map workflowCallTriggerKeys() { - final Map result = new LinkedHashMap<>(); - result.put(FIELD_INPUTS, GitHubWorkflowBundle.message("completion.context.inputs")); - result.put(FIELD_OUTPUTS, GitHubWorkflowBundle.message("completion.jobs.outputs")); - result.put(FIELD_SECRETS, GitHubWorkflowBundle.message("completion.context.secrets")); - return result; - } - private static Optional remoteUsesRef(final CompletionParameters parameters) { final String wholeText = parameters.getOriginalFile().getText(); final String beforeCaret = lineBeforeCaret(wholeText, parameters.getOffset()); @@ -569,7 +514,7 @@ private static Map localProjectPaths(final PsiElement position) private static Map localGitRefs(final PsiElement position, final String namespace, final String descriptionKey) { final Map result = new LinkedHashMap<>(); repositoryRoot(position) - .flatMap(CodeCompletion::gitDir) + .flatMap(WorkflowLocation.RepositoryResolver::gitDir) .ifPresent(gitDir -> { readLooseRefs(gitDir.resolve("refs").resolve(namespace), result, descriptionKey); readPackedRefs(gitDir.resolve("packed-refs"), "refs/" + namespace + "/", result, descriptionKey); @@ -631,26 +576,6 @@ private static Optional repositoryRoot(final PsiElement position) { .filter(path -> Files.isDirectory(path.resolve(".git")) || Files.isRegularFile(path.resolve(".git"))); } - private static Optional gitDir(final Path projectDir) { - final Path dotGit = projectDir.resolve(".git"); - if (Files.isDirectory(dotGit)) { - return Optional.of(dotGit); - } - if (!Files.isRegularFile(dotGit)) { - return Optional.empty(); - } - try { - final String value = Files.readString(dotGit).trim(); - if (!value.startsWith("gitdir:")) { - return Optional.empty(); - } - final Path path = Path.of(value.substring("gitdir:".length()).trim()); - return Optional.of(path.isAbsolute() ? path : projectDir.resolve(path).normalize()); - } catch (final IOException ignored) { - return Optional.empty(); - } - } - private static Map knownRemoteRefs(final PsiElement position, final String usesBase) { final Map result = new LinkedHashMap<>(); knownRemoteActions(position).stream() @@ -658,7 +583,7 @@ private static Map knownRemoteRefs(final PsiElement position, fi .flatMap(action -> action.remoteRefs().stream()) .forEach(ref -> result.putIfAbsent(ref, GitHubWorkflowBundle.message("completion.uses.ref.known"))); knownRemoteUsesValues(position).stream() - .map(CodeCompletion::splitRemoteUses) + .map(WorkflowCompletion::splitRemoteUses) .flatMap(Optional::stream) .filter(uses -> usesBase.equals(uses.base())) .forEach(uses -> result.putIfAbsent(uses.ref(), GitHubWorkflowBundle.message("completion.uses.ref.known"))); @@ -670,7 +595,7 @@ private static Map knownRemoteRefs(final PsiElement position, fi private static Map knownRemoteUses(final PsiElement position) { final Map result = new LinkedHashMap<>(); knownRemoteUsesValues(position).stream() - .map(CodeCompletion::splitRemoteUses) + .map(WorkflowCompletion::splitRemoteUses) .flatMap(Optional::stream) .forEach(uses -> result.putIfAbsent(uses.base(), GitHubWorkflowBundle.message("completion.uses.remote.known"))); return result; @@ -680,7 +605,7 @@ private static List knownRemoteUsesValues(final PsiElement position) { return Stream.concat( knownRemoteActions(position).stream().map(GitHubAction::usesValue), getAllElements(position.getContainingFile(), FIELD_USES).stream() - .map(PsiElementHelper::getText) + .map(WorkflowPsi::getText) .flatMap(Optional::stream) ) .filter(uses -> uses.contains("@") && !uses.startsWith(".")) @@ -749,17 +674,17 @@ private static Optional currentCallableAction(final CompletionPara private static Optional currentCallable(final PsiElement position) { return getParent(position, FIELD_WITH) .flatMap(with -> getParentStepOrJob(with) - .flatMap(callable -> PsiElementHelper.getChild(callable, FIELD_USES)) + .flatMap(callable -> WorkflowPsi.getChild(callable, FIELD_USES)) ) - .or(() -> currentStep(position).flatMap(callable -> PsiElementHelper.getChild(callable, FIELD_USES))) - .or(() -> currentJob(position).flatMap(callable -> PsiElementHelper.getChild(callable, FIELD_USES))) - .or(() -> currentStepOrJob(position).flatMap(callable -> PsiElementHelper.getChild(callable, FIELD_USES))); + .or(() -> currentStep(position).flatMap(callable -> WorkflowPsi.getChild(callable, FIELD_USES))) + .or(() -> currentJob(position).flatMap(callable -> WorkflowPsi.getChild(callable, FIELD_USES))) + .or(() -> currentStepOrJob(position).flatMap(callable -> WorkflowPsi.getChild(callable, FIELD_USES))); } private static Optional nearestPreviousUsesValue(final CompletionParameters parameters) { final String wholeText = parameters.getOriginalFile().getText(); - final int offset = boundedOffset(wholeText, parameters.getOffset()); - final int lineStart = currentLineStart(wholeText, offset); + final int offset = WorkflowLocation.boundedOffset(wholeText, parameters.getOffset()); + final int lineStart = WorkflowLocation.currentLineStart(wholeText, offset); final String beforeCaret = wholeText.substring(0, Math.min(lineStart, wholeText.length())); final String[] lines = beforeCaret.split("\\R"); for (int index = lines.length - 1; index >= 0; index--) { @@ -767,14 +692,14 @@ private static Optional nearestPreviousUsesValue(final CompletionParamet final String trimmed = line.trim(); if (trimmed.startsWith(FIELD_USES + ":")) { return Optional.of(trimmed.substring((FIELD_USES + ":").length()).trim()) - .map(PsiElementHelper::removeQuotes) - .filter(PsiElementHelper::hasText); + .map(WorkflowPsi::removeQuotes) + .filter(WorkflowPsi::hasText); } } return Optional.empty(); } - private static void addCodeCompletionItems(final CompletionResultSet resultSet, final String[] cbi, final PsiElement position, final String[] prefix) { + private static void addWorkflowCompletionItems(final CompletionResultSet resultSet, final String[] cbi, final PsiElement position, final String prefix) { final Map> completionResultMap = new HashMap<>(); for (int i = 0; i < cbi.length; i++) { //DON'T AUTO COMPLETE WHEN PREVIOUS ITEM IS NOT VALID @@ -788,8 +713,8 @@ private static void addCodeCompletionItems(final CompletionResultSet resultSet, } //ADD LOOKUP ELEMENTS ofNullable(completionResultMap.getOrDefault(cbi.length - 1, null)) - .map(CodeCompletion::toLookupItems) - .ifPresent(lookupElements -> addElementsWithPrefix(resultSet, prefix[0], lookupElements)); + .map(WorkflowCompletion::toLookupItems) + .ifPresent(lookupElements -> addElementsWithPrefix(resultSet, prefix, lookupElements)); } private static void addElementsWithPrefix(final CompletionResultSet resultSet, final String prefix, final List lookupElements) { @@ -834,7 +759,7 @@ private static Optional currentStep(final PsiElement position) private static Optional currentJob(final PsiElement position) { return ofNullable(position) .map(PsiElement::getContainingFile) - .map(file -> currentOrPrevious(position, getAllElements(file, FIELD_JOBS).stream().flatMap(jobs -> PsiElementHelper.getChildren(jobs, YAMLKeyValue.class).stream())) + .map(file -> currentOrPrevious(position, getAllElements(file, FIELD_JOBS).stream().flatMap(jobs -> WorkflowPsi.getChildren(jobs, YAMLKeyValue.class).stream())) .findFirst() .orElse(null)); } @@ -898,12 +823,12 @@ private static void handleFirstItem(final String[] cbi, final int i, final PsiEl case FIELD_STEPS -> completionItemMap.put(i, codeCompletionSteps(position)); case FIELD_JOBS -> completionItemMap.put(i, codeCompletionJobs(position)); case FIELD_ENVS -> completionItemMap.put(i, listEnvs(position)); - case FIELD_GITHUB -> completionItemMap.put(i, codeCompletionGithub()); - case FIELD_GITEA -> completionItemMap.put(i, codeCompletionGitea()); + case FIELD_GITHUB -> completionItemMap.put(i, codeCompletionContext(FIELD_GITHUB, ICON_ENV)); + case FIELD_GITEA -> completionItemMap.put(i, codeCompletionContext(FIELD_GITEA, ICON_ENV)); case FIELD_JOB -> completionItemMap.put(i, codeCompletionJob()); case FIELD_MATRIX -> completionItemMap.put(i, listMatrix(position)); - case FIELD_RUNNER -> completionItemMap.put(i, codeCompletionRunner()); - case FIELD_STRATEGY -> completionItemMap.put(i, codeCompletionStrategy()); + case FIELD_RUNNER -> completionItemMap.put(i, codeCompletionContext(FIELD_RUNNER, ICON_RUNNER)); + case FIELD_STRATEGY -> completionItemMap.put(i, codeCompletionContext(FIELD_STRATEGY, ICON_NODE)); case FIELD_INPUTS -> completionItemMap.put(i, listInputs(position)); case FIELD_SECRETS -> completionItemMap.put(i, listSecrets(position)); case FIELD_NEEDS -> completionItemMap.put(i, codeCompletionNeeds(position)); @@ -915,13 +840,17 @@ private static void handleFirstItem(final String[] cbi, final int i, final PsiEl completionItemMap.put(i, singletonList(completionItemOf(FIELD_JOBS, DEFAULT_VALUE_MAP.get(FIELD_DEFAULT).get().get(FIELD_JOBS), ICON_JOB))); } else if (getParent(position, "runs-on").isEmpty() && getParent(position, "os").isEmpty()) { // DEFAULT - addDefaultCodeCompletionItems(i, position, completionItemMap); + addDefaultWorkflowCompletionItems(i, position, completionItemMap); } } } } - private static void addDefaultCodeCompletionItems(final int i, final PsiElement position, final Map> completionItemMap) { + private static List codeCompletionContext(final String field, final NodeIcon icon) { + return completionItemsOf(DEFAULT_VALUE_MAP.get(field).get(), icon); + } + + private static void addDefaultWorkflowCompletionItems(final int i, final PsiElement position, final Map> completionItemMap) { ofNullable(DEFAULT_VALUE_MAP.getOrDefault(FIELD_DEFAULT, null)) .map(Supplier::get) .map(map -> { @@ -950,7 +879,7 @@ private static String getDefaultPrefix(final CompletionPsi completionPsi) { } private static String getDefaultPrefix(final String wholeText, final int caretOffset) { - final int offset = boundedOffset(wholeText, caretOffset); + final int offset = WorkflowLocation.boundedOffset(wholeText, caretOffset); if (offset < 1) { return ""; } @@ -964,24 +893,8 @@ private static String getDefaultPrefix(final String wholeText, final int caretOf .trim(); } - static String lineBeforeCaret(final String wholeText, final int rawOffset) { - final int offset = boundedOffset(wholeText, rawOffset); - final int lineStart = currentLineStart(wholeText, offset); - if (lineStart > offset) { - return ""; - } - return wholeText.substring(lineStart, offset).replace("IntellijIdeaRulezzz", ""); - } - - private static int currentLineStart(final String wholeText, final int offset) { - if (offset < 1) { - return 0; - } - return wholeText.lastIndexOf('\n', offset - 1) + 1; - } - - private static int boundedOffset(final String wholeText, final int rawOffset) { - return Math.max(0, Math.min(rawOffset, wholeText.length())); + public static String lineBeforeCaret(final String wholeText, final int rawOffset) { + return WorkflowLocation.lineBeforeCaret(wholeText, rawOffset); } private static void addLookupElements(final CompletionResultSet resultSet, final Map map, final NodeIcon icon, final char suffix) { @@ -999,19 +912,123 @@ private static List toLookupItems(final List items return items.stream().map(SimpleElement::toLookupElement).toList(); } - private record RemoteUses(String base, String ref) { + /** + * Opens workflow completion while the user types YAML structure and expression separators. + */ + public static class TypedAutoPopup extends TypedHandlerDelegate { + + @Override + public @NotNull Result checkAutoPopup( + final char typeChar, + @NotNull final Project project, + @NotNull final Editor editor, + @NotNull final PsiFile file + ) { + // Structural workflow completion is scheduled after the typed character lands in the document. + return Result.CONTINUE; + } + + @Override + public @NotNull Result charTyped( + final char typeChar, + @NotNull final Project project, + @NotNull final Editor editor, + @NotNull final PsiFile file + ) { + if (shouldAutoPopup(typeChar, editor, file)) { + scheduleWorkflowPopup(project, editor); + } + return Result.CONTINUE; + } + + static void scheduleWorkflowPopup(final Project project, final Editor editor) { + ApplicationManager.getApplication().invokeLater(() -> { + if (project.isDisposed() || editor.isDisposed()) { + return; + } + final PsiDocumentManager documentManager = PsiDocumentManager.getInstance(project); + documentManager.commitDocument(editor.getDocument()); + final PsiFile file = documentManager.getPsiFile(editor.getDocument()); + if (file != null && getWorkflowFile(file).isPresent()) { + AutoPopupController.getInstance(project).scheduleAutoPopup(editor); + } + }); + } + + public static boolean shouldAutoPopup(final char typeChar, final Editor editor, final PsiFile file) { + if (!WorkflowCompletion.workflowCompletionTrigger(typeChar) || editor == null || file == null) { + return false; + } + final int textLength = file.getTextLength(); + if (textLength <= 0) { + return getWorkflowFile(file).isPresent(); + } + final int offset = Math.max(0, Math.min(editor.getCaretModel().getOffset(), textLength - 1)); + final PsiElement element = Optional.ofNullable(file.findElementAt(offset)).orElse(file); + return getWorkflowFile(element).isPresent(); + } } - private record RemoteUsesRef(String usesBase, String prefix) { + /** + * Opens workflow key completion after pressing Enter below YAML mapping keys. + */ + public static class EnterAutoPopup extends EnterHandlerDelegateAdapter { + + @Override + public @NotNull Result postProcessEnter( + @NotNull final PsiFile file, + @NotNull final Editor editor, + @NotNull final DataContext dataContext + ) { + if (shouldAutoPopupAfterEnter(editor, file)) { + TypedAutoPopup.scheduleWorkflowPopup(file.getProject(), editor); + } + return Result.Continue; + } + + public static boolean shouldAutoPopupAfterEnter(final Editor editor, final PsiFile file) { + if (editor == null || file == null || getWorkflowFile(file).isEmpty()) { + return false; + } + final String textBeforeCaret = editor.getDocument() + .getImmutableCharSequence() + .subSequence(0, Math.min(editor.getCaretModel().getOffset(), editor.getDocument().getTextLength())) + .toString(); + final int currentLineStart = textBeforeCaret.lastIndexOf('\n'); + if (currentLineStart <= 0) { + return false; + } + final int previousLineStart = textBeforeCaret.lastIndexOf('\n', currentLineStart - 1) + 1; + final String previousLine = textBeforeCaret.substring(previousLineStart, currentLineStart).trim(); + return !previousLine.startsWith("#") && previousLine.endsWith(":"); + } } - private record StructureCompletion(Map items, char suffix) { + /** + * Keeps workflow auto-popup completion available in sparse YAML positions, such as the line after {@code on:}. + */ + public static class Confidence extends CompletionConfidence { + + @Override + public @NotNull ThreeState shouldSkipAutopopup( + final Editor editor, + final PsiElement contextElement, + final PsiFile psiFile, + final int offset + ) { + return getWorkflowFile(psiFile).isPresent() || getWorkflowFile(contextElement).isPresent() + ? ThreeState.NO + : ThreeState.UNSURE; + } } - private record YamlAncestor(int indent, String key) { + private record RemoteUses(String base, String ref) { } - private record YamlKeyContext(List path, String currentLine) { + private record RemoteUsesRef(String usesBase, String prefix) { + } + + private record StructureCompletion(Map items, char suffix) { } private record EventFilterContext(String event, String filter) { @@ -1019,4 +1036,14 @@ private record EventFilterContext(String event, String filter) { private record CompletionPsi(PsiElement position, int offset) { } + + private record CompletionTrigger( + CompletionParameters parameters, + CompletionResultSet resultSet, + CompletionPsi completionPsi, + PsiElement position, + Optional caretBracketItem, + String prefix + ) { + } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationProvider.java b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java similarity index 91% rename from src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationProvider.java rename to src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java index c562639..80d961e 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationProvider.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/entry/WorkflowDocumentationProvider.java @@ -1,6 +1,12 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.entry; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.lang.documentation.AbstractDocumentationProvider; import com.intellij.openapi.editor.Editor; @@ -18,19 +24,19 @@ import java.util.Optional; import java.util.Set; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SECRETS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_WITH; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStepOrJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; -import static com.github.yunabraska.githubworkflow.logic.Steps.listStepOutputs; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SECRETS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_WITH; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStepOrJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.syntax.Steps.listStepOutputs; import static java.util.Optional.ofNullable; -public final class WorkflowDocumentationProvider extends AbstractDocumentationProvider { +public class WorkflowDocumentationProvider extends AbstractDocumentationProvider { @Override public @Nullable PsiElement getCustomDocumentationElement( @@ -132,15 +138,15 @@ private static Optional parameterDoc(final YAMLKeyValue item, final private static Optional variableDoc(final PsiElement textElement, final int absoluteOffset) { final int offsetInElement = absoluteOffset - textElement.getTextRange().getStartOffset(); - final Optional target = ExpressionReferenceTargets.resolveAt(textElement, offsetInElement).stream().findFirst(); + final Optional target = WorkflowReferences.resolveAt(textElement, offsetInElement).stream().findFirst(); if (target.isPresent()) { return Optional.of(referenceDoc(target.get())); } - return ExpressionReferenceTargets.segmentAt(textElement, offsetInElement) + return WorkflowReferences.segmentAt(textElement, offsetInElement) .flatMap(WorkflowDocumentationProvider::contextDoc); } - private static DocPayload referenceDoc(final ExpressionReferenceTarget target) { + private static DocPayload referenceDoc(final WorkflowReferences.Target target) { return switch (target.kind()) { case "input" -> yamlParameterDoc(message("documentation.input.label"), true, target.segment().text(), target.target()); case "secret" -> yamlParameterDoc(message("documentation.secret.label"), false, target.segment().text(), target.target()); @@ -202,7 +208,7 @@ private static DocPayload actionDoc(final GitHubAction action) { private static DocPayload yamlParameterDoc(final String label, final boolean input, final String name, final PsiElement target) { final String details = target instanceof YAMLKeyValue keyValue - ? PsiElementHelper.getDescription(keyValue, input) + ? WorkflowPsi.getDescription(keyValue, input) : ""; return new DocPayload( label + " " + name, @@ -220,12 +226,12 @@ private static DocPayload yamlValueDoc(final String label, final String name, fi private static DocPayload stepDoc(final PsiElement target) { final YAMLKeyValue id = target instanceof YAMLKeyValue keyValue ? keyValue : null; - final Optional stepItem = PsiElementHelper.getParentStep(target); + final Optional stepItem = WorkflowPsi.getParentStep(target); final String name = id == null ? target.getText() : getText(id).orElse(id.getKeyText()); final String title = message("documentation.step.title", name); final StringBuilder html = new StringBuilder("

").append(escape(title)).append("

"); - stepItem.flatMap(step -> getChild(step, "name")).flatMap(PsiElementHelper::getText).ifPresent(value -> appendDetail(html, message("documentation.name.label"), value)); - stepItem.flatMap(step -> getChild(step, FIELD_USES)).flatMap(PsiElementHelper::getText).ifPresent(value -> appendDetail(html, message("documentation.uses.label"), value)); + stepItem.flatMap(step -> getChild(step, "name")).flatMap(WorkflowPsi::getText).ifPresent(value -> appendDetail(html, message("documentation.name.label"), value)); + stepItem.flatMap(step -> getChild(step, FIELD_USES)).flatMap(WorkflowPsi::getText).ifPresent(value -> appendDetail(html, message("documentation.uses.label"), value)); stepItem.flatMap(step -> getChild(step, FIELD_USES)) .map(GitHubActionCache::getAction) .filter(action -> action != null && action.isResolved()) @@ -235,7 +241,7 @@ private static DocPayload stepDoc(final PsiElement target) { : message("documentation.reusableWorkflow.label"), action.displayName()); appendParagraph(html, action.description()); }); - stepItem.flatMap(step -> getChild(step, "run")).flatMap(PsiElementHelper::getText).ifPresent(value -> appendDetail(html, message("documentation.run.label"), value)); + stepItem.flatMap(step -> getChild(step, "run")).flatMap(WorkflowPsi::getText).ifPresent(value -> appendDetail(html, message("documentation.run.label"), value)); stepItem.ifPresent(step -> appendList(html, message("documentation.outputs.title"), listStepOutputs(step).stream().map(output -> output.key()).toList())); return new DocPayload(title, html.toString(), title); } @@ -261,7 +267,7 @@ private static DocPayload stepOutputDoc(final String outputName, final PsiElemen } private static Optional stepOutputSource(final PsiElement target) { - final Optional stepItem = PsiElementHelper.getParentStep(target); + final Optional stepItem = WorkflowPsi.getParentStep(target); return stepItem.map(step -> { final String stepId = getText(step, "id").orElse(""); final String stepName = getText(step, "name").orElse(""); @@ -269,7 +275,7 @@ private static Optional stepOutputSource(final PsiElement targ final GitHubAction action = uses.map(GitHubActionCache::getAction) .filter(candidate -> candidate != null && candidate.isResolved()) .orElse(null); - return new StepOutputSource(stepId, stepName, uses.flatMap(PsiElementHelper::getText).orElse(""), action); + return new StepOutputSource(stepId, stepName, uses.flatMap(WorkflowPsi::getText).orElse(""), action); }); } @@ -288,9 +294,9 @@ private static DocPayload outputDoc(final String label, final String name, final } private static Optional outputSourceDetails(final YAMLKeyValue output) { - return PsiElementHelper.getTextElement(output) + return WorkflowPsi.getTextElement(output) .stream() - .flatMap(text -> ExpressionReferenceTargets.resolve(text).stream()) + .flatMap(text -> WorkflowReferences.resolve(text).stream()) .filter(target -> "step-output".equals(target.kind())) .findFirst() .map(target -> target.target() instanceof YAMLKeyValue uses && FIELD_USES.equals(uses.getKeyText()) @@ -418,7 +424,7 @@ private static String firstLine(final String text) { private static Optional textElement(final PsiElement element) { PsiElement current = element; while (current != null && current.getParent() != current) { - if (PsiElementHelper.isTextElement(current) || current instanceof YAMLScalar) { + if (WorkflowPsi.isTextElement(current) || current instanceof YAMLScalar) { return Optional.of(current); } current = current.getParent(); @@ -491,7 +497,7 @@ private Optional url() { } } - private static final class WorkflowDocumentationElement extends FakePsiElement { + private static class WorkflowDocumentationElement extends FakePsiElement { private final PsiElement delegate; private final DocPayload payload; diff --git a/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java b/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java new file mode 100644 index 0000000..c5fb553 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/git/RemoteActionProviders.java @@ -0,0 +1,623 @@ +package com.github.yunabraska.githubworkflow.git; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.intellij.ide.impl.ProjectUtil; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import org.jetbrains.plugins.github.authentication.GHAccountsUtil; +import org.jetbrains.plugins.github.authentication.accounts.GithubAccount; +import org.jetbrains.plugins.github.util.GHCompatibilityUtil; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +import java.util.function.Predicate; + +public class RemoteActionProviders { + + private static final Logger LOG = Logger.getInstance(RemoteActionProviders.class); + private static final HttpClient CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + public static Optional resolve(final String usesValue) { + return firstPresent(server -> resolve(server, usesValue)); + } + + public record Resolution( + String usesValue, + String name, + String downloadUrl, + String githubUrl, + String content, + boolean action, + List refs + ) { + } + + public static List latestRefs(final String usesBase, final int limit) { + if (limit < 1) { + return List.of(); + } + return firstUseful( + server -> RemoteUses.parseBase(server, usesBase) + .map(uses -> latestRefs(server, uses, limit)) + .orElseGet(List::of), + refs -> !refs.isEmpty(), + List.of() + ); + } + + public static Map searchUses(final String usesPrefix, final int limit) { + if (limit < 1) { + return Map.of(); + } + return firstUseful( + server -> RemoteUsesPrefix.parse(server, usesPrefix) + .map(prefix -> searchUses(server, prefix, limit)) + .orElseGet(Map::of), + items -> !items.isEmpty(), + Map.of() + ); + } + + private static Optional firstPresent(final Function> resolver) { + return Settings.getInstance().enabledServers().stream() + .map(resolver) + .flatMap(Optional::stream) + .findFirst(); + } + + private static T firstUseful( + final Function resolver, + final Predicate useful, + final T empty + ) { + return Settings.getInstance().enabledServers().stream() + .map(resolver) + .filter(useful) + .findFirst() + .orElse(empty); + } + + private static Optional resolve(final Server server, final String usesValue) { + return RemoteUses.parse(server, usesValue).flatMap(remoteUses -> resolve(server, remoteUses)); + } + + private static Optional resolve(final Server server, final RemoteUses uses) { + for (final String metadataPath : metadataPaths(server, uses)) { + final Optional content = getContent(server, uses.owner(), uses.repo(), metadataPath, uses.ref()); + if (content.isPresent()) { + final List refs = listRefs(server, uses.owner(), uses.repo()); + return Optional.of(new Resolution( + uses.usesValue(), + uses.owner() + "/" + uses.repo(), + content.get().downloadUrl(), + htmlUrl(server, uses, metadataPath), + content.get().content(), + !isWorkflowPath(metadataPath), + refs + )); + } + } + return Optional.empty(); + } + + private static List metadataPaths(final Server server, final RemoteUses uses) { + if (isWorkflowPath(uses.path())) { + return List.of(uses.path()); + } + final String base = uses.path().isBlank() ? "" : uses.path() + "/"; + return List.of(base + "action.yml", base + "action.yaml"); + } + + private static boolean isWorkflowPath(final String path) { + final String normalized = path.replace('\\', '/'); + return normalized.contains(".github/workflows/") + && (normalized.endsWith(".yml") || normalized.endsWith(".yaml")); + } + + private static Optional getContent( + final Server server, + final String owner, + final String repo, + final String path, + final String ref + ) { + final String url = server.apiUrl + "/repos/" + encode(owner) + "/" + encode(repo) + "/contents/" + encodePath(path) + "?ref=" + encode(ref); + return getJson(server, url).flatMap(json -> contentFromJson(json, url)); + } + + private static List listRefs(final Server server, final String owner, final String repo) { + final LinkedHashSet result = new LinkedHashSet<>(); + for (final String endpoint : List.of("branches", "tags")) { + final String url = server.apiUrl + "/repos/" + encode(owner) + "/" + encode(repo) + "/" + endpoint; + getJson(server, url).ifPresent(json -> namesFromJson(json).forEach(result::add)); + } + return List.copyOf(result); + } + + private static List latestRefs(final Server server, final RemoteUses uses, final int limit) { + final LinkedHashSet result = new LinkedHashSet<>(); + for (final String endpoint : List.of("tags", "branches")) { + final String url = server.apiUrl + "/repos/" + encode(uses.owner()) + "/" + encode(uses.repo()) + "/" + endpoint + "?per_page=" + limit; + getJson(server, url).ifPresent(json -> namesFromJson(json).forEach(result::add)); + if (result.size() >= limit) { + break; + } + } + return result.stream().limit(limit).toList(); + } + + private static Map searchUses(final Server server, final RemoteUsesPrefix prefix, final int limit) { + final Map result = new LinkedHashMap<>(); + for (final String endpoint : List.of("users", "orgs")) { + final String url = server.apiUrl + "/" + endpoint + "/" + encode(prefix.owner()) + "/repos?per_page=" + limit; + getJson(server, url).ifPresent(json -> repoCompletionsFromJson(json, prefix, limit).forEach(result::putIfAbsent)); + if (result.size() >= limit) { + break; + } + } + return result.entrySet().stream() + .limit(limit) + .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); + } + + private static Optional getJson(final Server server, final String url) { + for (final RemoteActionProviders.Authorizations.Authorization authorization : RemoteActionProviders.Authorizations.forApiUrl(server.apiUrl, server.tokenEnvVar, null)) { + try { + final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(3)) + .header("Accept", "application/json") + .header("User-Agent", "GitHub-Workflow-Plugin"); + if (authorization.authenticated()) { + builder.header("Authorization", authorization.authorizationHeader()); + } + final HttpResponse response = CLIENT.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() / 100 == 2) { + return Optional.of(JsonParser.parseString(response.body())); + } + if (!shouldTryNextAuthorization(response.statusCode())) { + return Optional.empty(); + } + } catch (final IOException exception) { + LOG.warn("Remote request failed [" + url + "]", exception); + return Optional.empty(); + } catch (final InterruptedException exception) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } catch (final RuntimeException exception) { + LOG.warn("Remote response failed [" + url + "]", exception); + return Optional.empty(); + } + } + return Optional.empty(); + } + + private static boolean shouldTryNextAuthorization(final int statusCode) { + return statusCode == 401 || statusCode == 403 || statusCode == 404 || statusCode == 429; + } + + private static Optional contentFromJson(final JsonElement json, final String fallbackDownloadUrl) { + if (!json.isJsonObject()) { + return Optional.empty(); + } + final JsonObject object = json.getAsJsonObject(); + final Optional rawContent = stringValue(object, "content"); + if (rawContent.isEmpty()) { + return Optional.empty(); + } + final String content = new String(Base64.getMimeDecoder().decode(rawContent.get()), StandardCharsets.UTF_8); + final String downloadUrl = stringValue(object, "download_url").orElse(fallbackDownloadUrl); + return Optional.of(new ContentResponse(content, downloadUrl)); + } + + private static List namesFromJson(final JsonElement json) { + final List result = new ArrayList<>(); + if (json.isJsonArray()) { + final JsonArray array = json.getAsJsonArray(); + for (final JsonElement element : array) { + if (element.isJsonObject()) { + stringValue(element.getAsJsonObject(), "name").ifPresent(result::add); + } + } + } + return result; + } + + private static Map repoCompletionsFromJson(final JsonElement json, final RemoteUsesPrefix prefix, final int limit) { + final Map result = new LinkedHashMap<>(); + if (json.isJsonArray()) { + final JsonArray array = json.getAsJsonArray(); + for (final JsonElement element : array) { + if (element.isJsonObject()) { + final JsonObject object = element.getAsJsonObject(); + final Optional name = stringValue(object, "name"); + final Optional fullName = stringValue(object, "full_name"); + if (name.filter(value -> value.startsWith(prefix.repoPrefix())).isPresent()) { + result.putIfAbsent( + fullName.orElse(prefix.owner() + "/" + name.get()), + stringValue(object, "description").orElse(GitHubWorkflowBundle.message("completion.remote.repository")) + ); + } + } + if (result.size() >= limit) { + break; + } + } + } + return result; + } + + private static Optional stringValue(final JsonObject object, final String name) { + return Optional.ofNullable(object.get(name)) + .filter(JsonElement::isJsonPrimitive) + .map(JsonElement::getAsString) + .filter(value -> !value.isBlank()); + } + + private static String htmlUrl(final Server server, final RemoteUses uses, final String metadataPath) { + final String base = server.webUrl + "/" + uses.owner() + "/" + uses.repo(); + if (isWorkflowPath(metadataPath)) { + return base + "/blob/" + uses.ref() + "/" + metadataPath; + } + final String actionPath = metadataPath.endsWith("/action.yml") + ? metadataPath.substring(0, metadataPath.length() - "/action.yml".length()) + : metadataPath.endsWith("/action.yaml") + ? metadataPath.substring(0, metadataPath.length() - "/action.yaml".length()) + : ""; + final String suffix = actionPath.isBlank() ? "" : "/" + actionPath; + return base + "/tree/" + uses.ref() + suffix + "#readme"; + } + + private static String encodePath(final String path) { + return List.of(path.split("/")).stream().map(RemoteActionProviders::encode).reduce((left, right) -> left + "/" + right).orElse(""); + } + + private static String encode(final String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private record ContentResponse(String content, String downloadUrl) { + } + + private record RemoteUses(String usesValue, String owner, String repo, String path, String ref) { + + static Optional parse(final Server server, final String value) { + if (value == null || value.isBlank() || value.startsWith(".")) { + return Optional.empty(); + } + final String stripped = stripServerPrefix(server, value.trim()).orElse(null); + if (stripped == null) { + return Optional.empty(); + } + final int atIndex = stripped.lastIndexOf('@'); + if (atIndex < 0 || atIndex == stripped.length() - 1) { + return Optional.empty(); + } + final String path = stripped.substring(0, atIndex); + final String ref = stripped.substring(atIndex + 1); + final String[] parts = path.split("/", 3); + if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { + return Optional.empty(); + } + return Optional.of(new RemoteUses(value.trim(), parts[0], parts[1], parts.length == 3 ? parts[2] : "", ref)); + } + + static Optional parseBase(final Server server, final String value) { + if (value == null || value.isBlank() || value.startsWith(".")) { + return Optional.empty(); + } + final String stripped = stripServerPrefix(server, value.trim()).orElse(null); + if (stripped == null) { + return Optional.empty(); + } + final int atIndex = stripped.lastIndexOf('@'); + final String path = atIndex < 0 ? stripped : stripped.substring(0, atIndex); + final String[] parts = path.split("/", 3); + if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { + return Optional.empty(); + } + return Optional.of(new RemoteUses(value.trim(), parts[0], parts[1], parts.length == 3 ? parts[2] : "", "")); + } + + private static Optional stripServerPrefix(final Server server, final String value) { + if (value.startsWith("http://") || value.startsWith("https://")) { + final String prefix = server.webUrl + "/"; + return value.startsWith(prefix) ? Optional.of(value.substring(prefix.length())) : Optional.empty(); + } + return Optional.of(value); + } + } + + private record RemoteUsesPrefix(String owner, String repoPrefix) { + + static Optional parse(final Server server, final String value) { + if (value == null || value.isBlank() || value.startsWith(".") || value.contains("@")) { + return Optional.empty(); + } + final String stripped = RemoteUses.stripServerPrefix(server, value.trim()).orElse(null); + if (stripped == null) { + return Optional.empty(); + } + final String[] parts = stripped.split("/", 3); + if (parts.length < 2 || parts[0].isBlank()) { + return Optional.empty(); + } + return Optional.of(new RemoteUsesPrefix(parts[0], parts[1])); + } + } + + public static class Settings { + + public static final String TYPE_GITHUB = "github"; + + private final CopyOnWriteArrayList testServers = new CopyOnWriteArrayList<>(); + + public static Settings getInstance() { + return ApplicationManager.getApplication().getService(Settings.class); + } + + public List enabledServers() { + final Map result = new LinkedHashMap<>(); + testServers.stream() + .map(Server::normalized) + .filter(Server::isValid) + .forEach(server -> result.put(server.key(), server)); + jetBrainsGithubServers().forEach(server -> result.putIfAbsent(server.key(), server)); + final Server defaultGitHub = defaultGitHub(); + result.putIfAbsent(defaultGitHub.key(), defaultGitHub); + return List.copyOf(result.values()); + } + + public void setCustomServers(final List servers) { + testServers.clear(); + Optional.ofNullable(servers).orElseGet(List::of).stream() + .map(Server::normalized) + .filter(Server::isValid) + .forEach(testServers::add); + } + + public static Server defaultGitHub() { + return new Server("GitHub", "https://github.com", "https://api.github.com", "", true); + } + + private static List jetBrainsGithubServers() { + try { + return GHAccountsUtil.getAccounts().stream() + .sorted((left, right) -> { + final int order = Integer.compare(accountOrder(left), accountOrder(right)); + return order == 0 ? left.getName().compareTo(right.getName()) : order; + }) + .map(account -> new Server( + account.getName(), + account.getServer().toUrl(), + account.getServer().toApiUrl(), + "", + true + )) + .map(Server::normalized) + .filter(Server::isValid) + .toList(); + } catch (final RuntimeException ignored) { + return List.of(); + } + } + + private static int accountOrder(final GithubAccount account) { + return account.getServer().isGithubDotCom() ? 0 : 1; + } + } + + public static class Server { + public final String type; + public final String name; + public final String webUrl; + public final String apiUrl; + public final String tokenEnvVar; + public final boolean enabled; + + public Server( + final String name, + final String webUrl, + final String apiUrl, + final String tokenEnvVar, + final boolean enabled + ) { + this.type = Settings.TYPE_GITHUB; + this.name = name; + this.webUrl = webUrl; + this.apiUrl = apiUrl; + this.tokenEnvVar = tokenEnvVar; + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isValid() { + return isEnabled() && hasText(webUrl) && hasText(apiUrl); + } + + public String authorizationHeader() { + return Optional.ofNullable(tokenEnvVar) + .filter(RemoteActionProviders::hasText) + .map(System::getenv) + .filter(RemoteActionProviders::hasText) + .map(token -> "Bearer " + token) + .orElse(""); + } + + public Server normalized() { + return new Server( + hasText(name) ? name.trim() : webUrl, + trimTrailingSlash(webUrl), + trimTrailingSlash(apiUrl), + Optional.ofNullable(tokenEnvVar).map(String::trim).orElse(""), + enabled + ); + } + + private String key() { + final Server normalized = normalized(); + return normalized.type + "|" + normalized.webUrl + "|" + normalized.apiUrl; + } + } + + public static class Authorizations { + + private static final List DEFAULT_ENV_TOKENS = List.of("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"); + + public static List forApiUrl(final String apiUrl, final String tokenEnvVar, final Project project) { + return forApiUrl(apiUrl, tokenEnvVar, project, System.getenv()); + } + + public static List forApiUrl( + final String apiUrl, + final String tokenEnvVar, + final Project project, + final Map environment + ) { + final LinkedHashMap result = new LinkedHashMap<>(); + orderedAccountsFor(apiUrl).stream() + .map(account -> authorization(account, project)) + .flatMap(Optional::stream) + .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); + envAuthorizations(tokenEnvVar, environment) + .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); + result.putIfAbsent(Authorization.anonymous().key(), Authorization.anonymous()); + return List.copyOf(result.values()); + } + + public static String settingsHint() { + return GitHubWorkflowBundle.message("workflow.run.auth.settings"); + } + + private static List orderedAccountsFor(final String apiUrl) { + return accounts().stream() + .sorted(Comparator + .comparingInt((GithubAccount account) -> accountPriority(account, apiUrl)) + .thenComparing(account -> account.getServer().toApiUrl()) + .thenComparing(GithubAccount::getName)) + .toList(); + } + + private static int accountPriority(final GithubAccount account, final String apiUrl) { + if (sameHost(account.getServer().toApiUrl(), apiUrl)) { + return 0; + } + return account.getServer().isGithubDotCom() ? 1 : 2; + } + + private static List accounts() { + try { + return new ArrayList<>(GHAccountsUtil.getAccounts()); + } catch (final RuntimeException ignored) { + return List.of(); + } + } + + private static Optional authorization(final GithubAccount account, final Project project) { + try { + return Optional.ofNullable(GHCompatibilityUtil.getOrRequestToken(account, project(project))) + .filter(RemoteActionProviders::hasText) + .map(token -> new Authorization(account.getName(), "Bearer " + token)); + } catch (final RuntimeException ignored) { + return Optional.empty(); + } + } + + private static List envAuthorizations(final String tokenEnvVar, final Map environment) { + final LinkedHashMap result = new LinkedHashMap<>(); + envAuthorization(tokenEnvVar, environment).ifPresent(authorization -> result.putIfAbsent(authorization.key(), authorization)); + DEFAULT_ENV_TOKENS.stream() + .filter(name -> !name.equals(tokenEnvVar)) + .map(name -> envAuthorization(name, environment)) + .flatMap(Optional::stream) + .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); + return List.copyOf(result.values()); + } + + private static Optional envAuthorization(final String tokenEnvVar, final Map environment) { + return Optional.ofNullable(tokenEnvVar) + .map(String::trim) + .filter(RemoteActionProviders::hasText) + .flatMap(name -> Optional.ofNullable(environment.get(name)) + .filter(RemoteActionProviders::hasText) + .map(token -> new Authorization(name, "Bearer " + token))); + } + + private static Project project(final Project project) { + return Optional.ofNullable(project) + .or(() -> Optional.ofNullable(ProjectUtil.getActiveProject())) + .orElseGet(() -> ProjectManager.getInstance().getDefaultProject()); + } + + private static boolean sameHost(final String left, final String right) { + final Optional leftHost = host(left); + final Optional rightHost = host(right); + return leftHost.isPresent() && leftHost.equals(rightHost); + } + + private static Optional host(final String value) { + try { + return Optional.ofNullable(URI.create(value).getHost()) + .map(String::toLowerCase); + } catch (final RuntimeException ignored) { + return Optional.empty(); + } + } + + public record Authorization(String source, String authorizationHeader) { + + public static Authorization anonymous() { + return new Authorization("anonymous", ""); + } + + public boolean authenticated() { + return hasText(authorizationHeader); + } + + String key() { + return source + "|" + authorizationHeader; + } + } + } + + private RemoteActionProviders() { + // static helper + } + + private static String trimTrailingSlash(final String value) { + final String trimmed = Optional.ofNullable(value).map(String::trim).orElse(""); + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + private static boolean hasText(final String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/git/WorkflowLocation.java b/src/main/java/com/github/yunabraska/githubworkflow/git/WorkflowLocation.java new file mode 100644 index 0000000..c0d1895 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/git/WorkflowLocation.java @@ -0,0 +1,341 @@ +package com.github.yunabraska.githubworkflow.git; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import org.jetbrains.yaml.psi.YAMLKeyValue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class WorkflowLocation { + + private final YAMLKeyValue keyValue; + private final List path; + private final boolean workflowFile; + + private WorkflowLocation( + final YAMLKeyValue keyValue, + final List path, + final boolean workflowFile + ) { + this.keyValue = keyValue; + this.path = List.copyOf(path); + this.workflowFile = workflowFile; + } + + public static Optional from(final PsiElement element) { + return Optional.ofNullable(element) + .filter(PsiElement::isValid) + .flatMap(WorkflowLocation::keyValueOf) + .map(keyValue -> new WorkflowLocation(keyValue, pathOf(keyValue), isWorkflowFile(keyValue))); + } + + public YAMLKeyValue keyValue() { + return keyValue; + } + + public List path() { + return path; + } + + public boolean workflowFile() { + return workflowFile; + } + + public static boolean pathEndsWith(final List path, final String... expected) { + if (path.size() < expected.length) { + return false; + } + final int offset = path.size() - expected.length; + for (int index = 0; index < expected.length; index++) { + if (!expected[index].equals(path.get(offset + index))) { + return false; + } + } + return true; + } + + public static boolean isChildOf(final List path, final String... expectedParent) { + return path.size() == expectedParent.length + 1 && pathEndsWith(path.subList(0, path.size() - 1), expectedParent); + } + + public static boolean pathMatches(final List path, final String... pattern) { + if (path.size() != pattern.length) { + return false; + } + for (int index = 0; index < pattern.length; index++) { + if (!"*".equals(pattern[index]) && !pattern[index].equals(path.get(index))) { + return false; + } + } + return true; + } + + public static Optional keyContextAt(final PsiElement position, final int rawOffset) { + return Optional.ofNullable(position) + .map(PsiElement::getContainingFile) + .map(file -> keyContextFromText(file.getText(), rawOffset)); + } + + public static KeyContext keyContextFromText(final String wholeText, final int rawOffset) { + final int offset = boundedOffset(wholeText, rawOffset); + final int lineStart = currentLineStart(wholeText, offset); + final String currentLine = lineBeforeCaret(wholeText, offset); + final int currentIndent = leadingSpaces(currentLine); + final List stack = new ArrayList<>(); + wholeText.substring(0, Math.min(lineStart, wholeText.length())).lines().forEach(raw -> { + final String content = raw.trim(); + if (!content.isBlank() && !content.startsWith("#")) { + currentLineKey(content).ifPresent(key -> { + final int indent = leadingSpaces(raw); + while (!stack.isEmpty() && stack.get(stack.size() - 1).indent() >= indent) { + stack.remove(stack.size() - 1); + } + stack.add(new YamlAncestor(indent, key)); + }); + } + }); + while (!stack.isEmpty() && stack.get(stack.size() - 1).indent() >= currentIndent) { + stack.remove(stack.size() - 1); + } + return new KeyContext(stack.stream().map(YamlAncestor::key).toList(), currentLine); + } + + public static List pathOf(final YAMLKeyValue element) { + final List result = new ArrayList<>(); + PsiElement current = element.getParent(); + while (current != null && current != element.getContainingFile()) { + if (current instanceof YAMLKeyValue keyValue) { + result.add(0, keyValue.getKeyText()); + } + current = current.getParent(); + } + return result; + } + + public static Optional currentLineKey(final String currentLine) { + return yamlKey(currentLine.trim()); + } + + public static boolean isValueCompletion(final String currentLine) { + return currentLine.replace("IntellijIdeaRulezzz", "").matches("\\s*[^:#]+:\\s*.*"); + } + + public static String lineBeforeCaret(final String wholeText, final int rawOffset) { + final int offset = boundedOffset(wholeText, rawOffset); + final int lineStart = currentLineStart(wholeText, offset); + if (lineStart > offset) { + return ""; + } + return wholeText.substring(lineStart, offset).replace("IntellijIdeaRulezzz", ""); + } + + public static int currentLineStart(final String wholeText, final int offset) { + if (offset < 1) { + return 0; + } + return wholeText.lastIndexOf('\n', offset - 1) + 1; + } + + public static int boundedOffset(final String wholeText, final int rawOffset) { + return Math.max(0, Math.min(rawOffset, wholeText.length())); + } + + private static Optional yamlKey(final String content) { + final String normalized = content.startsWith("- ") ? content.substring(2).trim() : content; + final int separator = normalized.indexOf(':'); + if (separator <= 0) { + return Optional.empty(); + } + return Optional.of(stripYamlKeyQuotes(normalized.substring(0, separator).trim())) + .filter(key -> !key.isBlank()); + } + + private static int leadingSpaces(final String value) { + int result = 0; + while (result < value.length() && value.charAt(result) == ' ') { + result++; + } + return result; + } + + private static String stripYamlKeyQuotes(final String value) { + if (value.length() >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) { + return value.substring(1, value.length() - 1); + } + return value; + } + + private static Optional keyValueOf(final PsiElement element) { + PsiElement current = element; + while (current != null && current != element.getContainingFile()) { + if (current instanceof YAMLKeyValue keyValue) { + return Optional.of(keyValue); + } + current = current.getParent(); + } + return Optional.empty(); + } + + private static boolean isWorkflowFile(final PsiElement element) { + return WorkflowYaml.getWorkflowFile(element) + .filter(WorkflowYaml::isWorkflowFile) + .isPresent(); + } + + public static class RepositoryResolver { + + private static final Pattern HTTPS_REMOTE = Pattern.compile("https?://([^/]+)/([^/]+)/([^/]+?)(?:[.]git)?/?$"); + private static final Pattern SSH_REMOTE = Pattern.compile("(?:git@|ssh://git@)([^:/]+)[:/]([^/]+)/([^/]+?)(?:[.]git)?/?$"); + + public Optional resolve(final Project project) { + return Optional.ofNullable(project) + .map(ProjectUtil::guessProjectDir) + .map(VirtualFile::getPath) + .map(Path::of) + .flatMap(this::resolve); + } + + public Optional resolve(final Project project, final VirtualFile file) { + return repositoryRoot(file) + .flatMap(this::resolve) + .or(() -> resolve(project)); + } + + public Optional resolve(final Path projectDir) { + return readGitConfig(projectDir) + .flatMap(RepositoryResolver::firstOriginUrl) + .flatMap(RepositoryResolver::fromRemoteUrl); + } + + public Optional branch(final Project project) { + return Optional.ofNullable(project) + .map(ProjectUtil::guessProjectDir) + .map(VirtualFile::getPath) + .map(Path::of) + .flatMap(this::branch); + } + + public Optional branch(final Project project, final VirtualFile file) { + return repositoryRoot(file) + .flatMap(this::branch) + .or(() -> branch(project)); + } + + public Optional branch(final Path projectDir) { + return gitDir(projectDir) + .map(dir -> dir.resolve("HEAD")) + .flatMap(RepositoryResolver::readString) + .flatMap(RepositoryResolver::branchName); + } + + public static Optional fromRemoteUrl(final String remoteUrl) { + return match(HTTPS_REMOTE, remoteUrl).or(() -> match(SSH_REMOTE, remoteUrl)); + } + + public static Optional branchName(final String head) { + final String prefix = "ref: refs/heads/"; + return Optional.ofNullable(head) + .map(String::trim) + .filter(value -> value.startsWith(prefix)) + .map(value -> value.substring(prefix.length())) + .filter(value -> !value.isBlank()); + } + + private static Optional match(final Pattern pattern, final String remoteUrl) { + final Matcher matcher = pattern.matcher(Optional.ofNullable(remoteUrl).orElse("").trim()); + if (!matcher.matches()) { + return Optional.empty(); + } + final String host = matcher.group(1); + final String owner = matcher.group(2); + final String repo = matcher.group(3); + final String webUrl = "https://" + host; + final String apiUrl = "github.com".equalsIgnoreCase(host) + ? "https://api.github.com" + : webUrl + "/api/v3"; + return Optional.of(new Repository(webUrl, apiUrl, owner, repo)); + } + + private static Optional readGitConfig(final Path projectDir) { + final Path config = projectDir.resolve(".git").resolve("config"); + if (!Files.isRegularFile(config)) { + return Optional.empty(); + } + return readString(config); + } + + public static Optional gitDir(final Path projectDir) { + final Path dotGit = projectDir.resolve(".git"); + if (Files.isDirectory(dotGit)) { + return Optional.of(dotGit); + } + if (!Files.isRegularFile(dotGit)) { + return Optional.empty(); + } + return readString(dotGit) + .map(String::trim) + .filter(value -> value.startsWith("gitdir:")) + .map(value -> value.substring("gitdir:".length()).trim()) + .filter(value -> !value.isBlank()) + .map(Path::of) + .map(path -> path.isAbsolute() ? path : projectDir.resolve(path).normalize()); + } + + private static Optional readString(final Path path) { + try { + return Optional.of(Files.readString(path)); + } catch (final IOException ignored) { + return Optional.empty(); + } + } + + private static Optional repositoryRoot(final VirtualFile file) { + Path current = Optional.ofNullable(file) + .map(VirtualFile::getPath) + .map(Path::of) + .map(Path::getParent) + .orElse(null); + while (current != null) { + if (Files.isRegularFile(current.resolve(".git").resolve("config")) || Files.isRegularFile(current.resolve(".git"))) { + return Optional.of(current); + } + current = current.getParent(); + } + return Optional.empty(); + } + + private static Optional firstOriginUrl(final String config) { + boolean inOrigin = false; + for (final String line : config.split("\\R")) { + final String trimmed = line.trim(); + if (trimmed.startsWith("[remote ")) { + inOrigin = trimmed.equals("[remote \"origin\"]"); + continue; + } + if (inOrigin && trimmed.startsWith("url =")) { + return Optional.of(trimmed.substring("url =".length()).trim()).filter(value -> !value.isBlank()); + } + } + return Optional.empty(); + } + } + + public record KeyContext(List path, String currentLine) { + } + + public record Repository(String webUrl, String apiUrl, String owner, String repo) { + } + + private record YamlAncestor(int indent, String key) { + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/AutoPopupInsertHandler.java b/src/main/java/com/github/yunabraska/githubworkflow/helper/AutoPopupInsertHandler.java deleted file mode 100644 index dde14ba..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/AutoPopupInsertHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import com.intellij.codeInsight.AutoPopupController; -import com.intellij.codeInsight.completion.InsertHandler; -import com.intellij.codeInsight.completion.InsertionContext; -import com.intellij.codeInsight.lookup.LookupElement; -import com.intellij.openapi.editor.Document; -import org.jetbrains.annotations.NotNull; - -public class AutoPopupInsertHandler implements InsertHandler { - public static final AutoPopupInsertHandler INSTANCE = new AutoPopupInsertHandler<>(); - - @Override - public void handleInsert(@NotNull final InsertionContext context, @NotNull final T item) { - AutoPopupController.getInstance(context.getProject()).scheduleAutoPopup(context.getEditor()); - } - - public static void addSuffix(final InsertionContext ctx, final LookupElement item, final char suffix) { - if (suffix != Character.MIN_VALUE) { - final String key = item.getLookupString(); - final int startOffset = ctx.getStartOffset(); - final Document document = ctx.getDocument(); - final CharSequence documentChars = document.getCharsSequence(); - final int tailOffset = ctx.getTailOffset(); - final String toInsert = toInsertString(suffix, documentChars, tailOffset); - - document.replaceString(startOffset, getEndIndex(ctx, suffix, documentChars, tailOffset), key + toInsert); - ctx.getEditor().getCaretModel().moveToOffset(startOffset + (key + toInsert).length()); - - if (suffix == '.') { - AutoPopupInsertHandler.INSTANCE.handleInsert(ctx, item); - } - } - } - - private static int getEndIndex(final InsertionContext ctx, final char suffix, final CharSequence documentChars, final int tailOffset) { - int result = tailOffset; - if (ctx.getCompletionChar() == '\t') { - while (result < documentChars.length() - && documentChars.charAt(result) != suffix - && !isLineBreak(documentChars.charAt(result)) - ) { - result++; - } - } - return result; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - private static boolean isLineBreak(final char c) { - return c == '\n' || c == '\r'; - } - - @NotNull - private static String toInsertString(final char suffix, final CharSequence documentChars, final int tailOffset) { - final StringBuilder sb = new StringBuilder(); - sb.append(suffix); - final boolean isNextCharSpace = tailOffset < documentChars.length() && documentChars.charAt(tailOffset) == ' '; - if (suffix != '.' && !isNextCharSpace) { - sb.append(' '); - } - return sb.toString(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/FileDownloader.java b/src/main/java/com/github/yunabraska/githubworkflow/helper/FileDownloader.java deleted file mode 100644 index 2bfeb1a..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/FileDownloader.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import com.intellij.ide.impl.ProjectUtil; -import com.intellij.openapi.application.ApplicationInfo; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.project.ProjectManager; -import com.intellij.util.concurrency.AppExecutorUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.plugins.github.api.GithubApiRequest; -import org.jetbrains.plugins.github.api.GithubApiRequestExecutor; -import org.jetbrains.plugins.github.api.GithubApiResponse; -import org.jetbrains.plugins.github.authentication.GHAccountsUtil; -import org.jetbrains.plugins.github.authentication.accounts.GithubAccount; -import org.jetbrains.plugins.github.util.GHCompatibilityUtil; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URI; -import java.util.Optional; -import java.util.Comparator; -import java.util.concurrent.Future; - -import static java.util.Optional.ofNullable; - -public class FileDownloader { - - private static final Logger LOG = Logger.getInstance(GitHubWorkflowHelper.class); - - private FileDownloader() { - // static helper class - } - - public static String downloadFileFromGitHub(final String downloadUrl) { - return GHAccountsUtil.getAccounts().stream() - .sorted(Comparator.comparingInt(account -> account.getServer().isGithubDotCom() ? 0 : 1)) - .map(account -> downloadFromGitHub(downloadUrl, account)) - .filter(PsiElementHelper::hasText) - .findFirst() - .orElseGet(() -> downloadContent(downloadUrl)); - } - - - @SuppressWarnings({"java:S2142"}) - public static String downloadContent(final String urlString) { - LOG.info("Download [" + urlString + "]"); - try { - final ApplicationInfo applicationInfo = ApplicationInfo.getInstance(); - final Future future = AppExecutorUtil.getAppExecutorService() - .submit(() -> downloadSync(urlString, applicationInfo.getBuild().getProductCode() + "/" + applicationInfo.getFullVersion())); - return future.get(); - } catch (final Exception e) { - LOG.warn("Execution failed for [" + urlString + "] message [" + (e instanceof NullPointerException ? null : e.getMessage()) + "]"); - } - return ""; - } - - public static String downloadSync(final String urlString, final String userAgent) { - HttpURLConnection connection = null; - try { - connection = (HttpURLConnection) new URI(urlString).toURL().openConnection(); - connection.setRequestMethod("GET"); - connection.setConnectTimeout(1000); - connection.setReadTimeout(1000); - connection.setRequestProperty("User-Agent", userAgent); - connection.setRequestProperty("Client-Name", "GitHub Workflow Plugin"); - - if (connection.getResponseCode() / 100 != 2) { - throw new IOException("HTTP error code: " + connection.getResponseCode()); - } - - try (final BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { - final StringBuilder response = new StringBuilder(); - String inputLine; - while ((inputLine = in.readLine()) != null) { - response.append(inputLine).append(System.lineSeparator()); - } - return response.toString(); - } - } catch (final Exception ignored) { - return ""; - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } - - private static String downloadFromGitHub(final String downloadUrl, final GithubAccount account) { - return ofNullable(ProjectUtil.getActiveProject()) - .or(() -> Optional.of(ProjectManager.getInstance().getDefaultProject())) - .map(project -> GHCompatibilityUtil.getOrRequestToken(account, project)) - .map(token -> downloadContent(downloadUrl, account, token)) - .orElse(""); - } - - private static String downloadContent(final String downloadUrl, final GithubAccount account, final String token) { - try { - return GithubApiRequestExecutor.Factory.getInstance().create(account.getServer(), token).execute(new GithubApiRequest.Get<>(downloadUrl) { - @Override - public String extractResult(final @NotNull GithubApiResponse response) { - try { - return response.handleBody(inputStream -> { - try (final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) { - final StringBuilder stringBuilder = new StringBuilder(); - String line; - while ((line = bufferedReader.readLine()) != null) { - stringBuilder.append(line).append(System.lineSeparator()); - } - return stringBuilder.toString(); - } - }); - } catch (final IOException ignored) { - return ""; - } - } - }); - } catch (final Exception ignored) { - return ""; - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/ListenerService.java b/src/main/java/com/github/yunabraska/githubworkflow/helper/ListenerService.java deleted file mode 100644 index 1366526..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/ListenerService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import com.intellij.openapi.Disposable; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.Service; -import com.intellij.openapi.project.Project; - -@Service -public final class ListenerService implements Disposable { - @Override - public void dispose() { - } - - @SuppressWarnings("unused") - public static ListenerService getInstance() { - return ApplicationManager.getApplication().getService(ListenerService.class); - } - - public static ListenerService getInstance(final Project project) { - return project.getService(ListenerService.class); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementChangeListener.java b/src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementChangeListener.java deleted file mode 100644 index ce3ae87..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementChangeListener.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import com.github.yunabraska.githubworkflow.services.GitHubActionCache; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.psi.PsiTreeChangeAdapter; -import com.intellij.psi.PsiTreeChangeEvent; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static java.util.Optional.ofNullable; - -public class PsiElementChangeListener extends PsiTreeChangeAdapter { - - // ON PsiElement CHANGE - @Override - public void childReplaced(@NotNull final PsiTreeChangeEvent event) { - ofNullable(event.getNewChild()) - .filter(psiElement -> getWorkflowFile(psiElement).isPresent()) - .flatMap(psiElement -> PsiElementHelper.getParent(psiElement, FIELD_USES)) - .map(GitHubActionCache::getAction) - .filter(action -> !action.isResolved()) - .map(List::of) - .ifPresent(GitHubActionCache::resolveActionsAsync); - } - - // ON INSERT / PASTE - @Override - public void childrenChanged(@NotNull final PsiTreeChangeEvent event) { - ofNullable(event.getParent()) - .filter(psiElement -> getWorkflowFile(psiElement).isPresent()) - .map(psiElement -> PsiElementHelper.getAllElements(psiElement, FIELD_USES)) - .map(usesList -> usesList.stream().map(GitHubActionCache::getAction).filter(Objects::nonNull).filter(action -> !action.isLocal()).filter(action -> !action.isResolved()).toList()) - .ifPresent(GitHubActionCache::resolveActionsAsync); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/i18n/GitHubWorkflowBundle.java b/src/main/java/com/github/yunabraska/githubworkflow/i18n/GitHubWorkflowBundle.java new file mode 100644 index 0000000..c72f3f6 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/i18n/GitHubWorkflowBundle.java @@ -0,0 +1,174 @@ +package com.github.yunabraska.githubworkflow.i18n; + +import com.intellij.DynamicBundle; +import com.intellij.ide.BrowserUtil; +import com.intellij.openapi.application.ApplicationInfo; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.openapi.diagnostic.ErrorReportSubmitter; +import com.intellij.openapi.diagnostic.IdeaLoggingEvent; +import com.intellij.openapi.diagnostic.SubmittedReportInfo; +import com.intellij.openapi.extensions.PluginDescriptor; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.util.Consumer; +import com.intellij.util.xmlb.XmlSerializerUtil; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.PropertyKey; + +import java.awt.Component; +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.Optional; +import java.util.ResourceBundle; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Optional.ofNullable; + +public class GitHubWorkflowBundle { + + @NonNls + private static final String BUNDLE = "messages.GitHubWorkflowBundle"; + private static final DynamicBundle INSTANCE = new DynamicBundle(GitHubWorkflowBundle.class, BUNDLE); + + public static String message(@PropertyKey(resourceBundle = BUNDLE) final String key, final Object... params) { + final var locale = Settings.maybeInstance().flatMap(Settings::localeOverride); + if (locale.isPresent()) { + return messageFor(locale.get(), key, params); + } + return INSTANCE.getMessage(key, params); + } + + static String messageFor(final Locale locale, final @PropertyKey(resourceBundle = BUNDLE) String key, final Object... params) { + try { + final ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE, locale); + final String pattern = bundle.getString(key); + return new MessageFormat(pattern, locale).format(params); + } catch (final MissingResourceException ignored) { + return INSTANCE.getMessage(key, params); + } + } + + private GitHubWorkflowBundle() { + // static bundle + } + + /** + * Persistent user settings for the GitHub Workflow plugin. + */ + @State(name = "GitHubWorkflowPluginSettings", storages = {@Storage("githubWorkflowPluginSettings.xml")}) + public static class Settings implements PersistentStateComponent { + + public static final String SYSTEM_LANGUAGE = ""; + + public static class StateData { + public String languageTag = SYSTEM_LANGUAGE; + } + + private final StateData state = new StateData(); + + public static Settings getInstance() { + return ApplicationManager.getApplication().getService(Settings.class); + } + + public static Optional maybeInstance() { + try { + return Optional.ofNullable(ApplicationManager.getApplication()) + .map(application -> application.getService(Settings.class)); + } catch (final RuntimeException ignored) { + return Optional.empty(); + } + } + + @Override + public @Nullable StateData getState() { + return state; + } + + @Override + public void loadState(@NotNull final StateData state) { + XmlSerializerUtil.copyBean(state, this.state); + } + + public String languageTag() { + return state.languageTag == null ? SYSTEM_LANGUAGE : state.languageTag; + } + + public Settings languageTag(final String languageTag) { + state.languageTag = languageTag == null ? SYSTEM_LANGUAGE : languageTag.trim(); + return this; + } + + public Optional localeOverride() { + final String languageTag = languageTag(); + return languageTag.isBlank() ? Optional.empty() : Optional.of(Locale.forLanguageTag(languageTag.replace('_', '-'))); + } + } + + public static class ErrorReporter extends ErrorReportSubmitter { + + @NonNls + private static final String REPORT_URL = "https://github.com/YunaBraska/github-workflow-plugin/issues/new?labels=bug&template=---bug-report.md"; + + @NotNull + @Override + public String getReportActionText() { + return message("error.report.action"); + } + + @Override + public boolean submit(final IdeaLoggingEvent @NotNull [] events, + @Nullable final String additionalInfo, + @NotNull final Component parentComponent, + @NotNull final Consumer consumer) { + if (events.length == 0) { + consumer.consume(new SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.FAILED)); + return false; + } + + final IdeaLoggingEvent event = events[0]; + final String throwableText = event.getThrowableText(); + final StringBuilder url = new StringBuilder(REPORT_URL); + + url.append(URLEncoder.encode(StringUtil.splitByLines(throwableText)[0], UTF_8)); + ofNullable(event.getThrowable()) + .map(Throwable::getMessage) + .or(() -> Optional.of(throwableText).map(title -> StringUtil.splitByLines(title)[0])) + .map(title -> "&title=" + URLEncoder.encode(title, UTF_8)) + .ifPresent(url::append); + + url.append("&body="); + url.append(URLEncoder.encode("\n\n### " + message("error.report.description") + "\n", UTF_8)); + url.append(URLEncoder.encode(StringUtil.defaultIfEmpty(additionalInfo, ""), UTF_8)); + + url.append(URLEncoder.encode("\n\n### " + message("error.report.steps") + "\n", UTF_8)); + url.append(URLEncoder.encode(message("error.report.sample"), UTF_8)); + + url.append(URLEncoder.encode("\n\n### " + message("error.report.message") + "\n", UTF_8)); + url.append(URLEncoder.encode(StringUtil.defaultIfEmpty(event.getMessage(), ""), UTF_8)); + + url.append(URLEncoder.encode("\n\n### " + message("error.report.runtime") + "\n", UTF_8)); + final PluginDescriptor descriptor = getPluginDescriptor(); + url.append(URLEncoder.encode(message("error.report.pluginVersion", descriptor.getVersion()) + "\n", UTF_8)); + final String ideInfo = ApplicationInfo.getInstance().getFullApplicationName() + + " (" + ApplicationInfo.getInstance().getBuild().asString() + ")"; + url.append(URLEncoder.encode(message("error.report.ide", ideInfo) + "\n", UTF_8)); + url.append(URLEncoder.encode(message("error.report.os", SystemInfo.OS_NAME + " " + SystemInfo.OS_VERSION), UTF_8)); + + url.append(URLEncoder.encode("\n\n### " + message("error.report.stacktrace") + "\n", UTF_8)); + url.append(URLEncoder.encode("```\n", UTF_8)); + url.append(URLEncoder.encode(throwableText, UTF_8)); + url.append(URLEncoder.encode("```\n", UTF_8)); + + BrowserUtil.browse(url.toString()); + consumer.consume(new SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.NEW_ISSUE)); + return true; + } + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/GitHub.java b/src/main/java/com/github/yunabraska/githubworkflow/logic/GitHub.java deleted file mode 100644 index cac16b0..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/GitHub.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.yunabraska.githubworkflow.logic; - -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.intellij.lang.annotation.AnnotationHolder; -import com.intellij.psi.impl.source.tree.LeafPsiElement; - -import java.util.ArrayList; -import java.util.List; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_GITEA; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_GITHUB; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; -import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; - -public class GitHub { - - public static void highLightGitHub(final AnnotationHolder holder, final LeafPsiElement element, final SimpleElement[] parts) { - ifEnoughItems(holder, element, parts, 2, -1, envId -> isDefinedItem0(element, holder, envId, new ArrayList<>(DEFAULT_VALUE_MAP.get(FIELD_GITHUB).get().keySet()))); - } - - public static List codeCompletionGithub() { - return completionItemsOf(DEFAULT_VALUE_MAP.get(FIELD_GITHUB).get(), ICON_ENV); - } - - public static void highLightGitea(final AnnotationHolder holder, final LeafPsiElement element, final SimpleElement[] parts) { - ifEnoughItems(holder, element, parts, 2, -1, envId -> isDefinedItem0(element, holder, envId, new ArrayList<>(DEFAULT_VALUE_MAP.get(FIELD_GITEA).get().keySet()))); - } - - public static List codeCompletionGitea() { - return completionItemsOf(DEFAULT_VALUE_MAP.get(FIELD_GITEA).get(), ICON_ENV); - } - - private GitHub() { - // static helper class - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Runner.java b/src/main/java/com/github/yunabraska/githubworkflow/logic/Runner.java deleted file mode 100644 index 36bc033..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Runner.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.yunabraska.githubworkflow.logic; - -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.intellij.lang.annotation.AnnotationHolder; -import com.intellij.psi.impl.source.tree.LeafPsiElement; - -import java.util.ArrayList; -import java.util.List; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUNNER; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_RUNNER; -import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; - -public class Runner { - - public static void highlightRunner(final AnnotationHolder holder, final LeafPsiElement element, final SimpleElement[] parts) { - ifEnoughItems(holder, element, parts, 2, 2, runnerId -> isDefinedItem0(element, holder, runnerId, new ArrayList<>(DEFAULT_VALUE_MAP.get(FIELD_RUNNER).get().keySet()))); - } - - public static List codeCompletionRunner() { - return completionItemsOf(DEFAULT_VALUE_MAP.get(FIELD_RUNNER).get(), ICON_RUNNER); - } - - private Runner() { - // static helper class - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Strategy.java b/src/main/java/com/github/yunabraska/githubworkflow/logic/Strategy.java deleted file mode 100644 index 0eec442..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Strategy.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.yunabraska.githubworkflow.logic; - -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.intellij.lang.annotation.AnnotationHolder; -import com.intellij.psi.impl.source.tree.LeafPsiElement; - -import java.util.ArrayList; -import java.util.List; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STRATEGY; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_NODE; -import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; - -public class Strategy { - - public static void highlightStrategy(final AnnotationHolder holder, final LeafPsiElement element, final SimpleElement[] parts) { - ifEnoughItems(holder, element, parts, 2, 2, field -> isDefinedItem0(element, holder, field, new ArrayList<>(DEFAULT_VALUE_MAP.get(FIELD_STRATEGY).get().keySet()))); - } - - public static List codeCompletionStrategy() { - return completionItemsOf(DEFAULT_VALUE_MAP.get(FIELD_STRATEGY).get(), ICON_NODE); - } - - private Strategy() { - // static helper class - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/model/CustomClickAction.java b/src/main/java/com/github/yunabraska/githubworkflow/model/CustomClickAction.java index bc72f83..878971f 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/CustomClickAction.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/CustomClickAction.java @@ -5,7 +5,7 @@ import com.intellij.psi.PsiElement; import org.jetbrains.annotations.NotNull; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getProject; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getProject; public class CustomClickAction extends AnAction { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubAction.java b/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubAction.java index b1d8a26..9fd4d6f 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubAction.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubAction.java @@ -1,7 +1,7 @@ package com.github.yunabraska.githubworkflow.model; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.services.RemoteActionProviders; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; @@ -13,7 +13,6 @@ import com.intellij.psi.PsiFile; import com.intellij.psi.PsiFileFactory; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.jetbrains.yaml.YAMLFileType; import org.jetbrains.yaml.psi.YAMLKeyValue; @@ -35,13 +34,13 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.CACHE_ONE_DAY; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_INPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SECRETS; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.hasText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.CACHE_ONE_DAY; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_INPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SECRETS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.hasText; import static java.lang.Boolean.parseBoolean; import static java.util.Collections.unmodifiableMap; import static java.util.Optional.of; @@ -80,49 +79,85 @@ public static GitHubAction createSchemaAction(final String url, final String con .expiryTime(System.currentTimeMillis() + (CACHE_ONE_DAY * 30)); } - @SuppressWarnings("java:S3776") public static GitHubAction createGithubAction(final boolean isLocal, final String usesValue, final String absolutePath) { - final int tagIndex = usesValue.indexOf("@"); - final int userNameIndex = usesValue.indexOf("/"); - final int repoNameIndex = usesValue.indexOf("/", userNameIndex + 1); - final String ref = tagIndex != -1 ? usesValue.substring(tagIndex + 1) : null; - final String tmpName; - String slug = null; - String tmpSub = null; - - final boolean isAction = isLocal ? !isWorkflowFile(usesValue) : (!absolutePath.contains(".yaml") && !absolutePath.contains(".yml") && !absolutePath.contains(".action.y")); - - // START [EXTRACT PARTS] - if (tagIndex != -1 && userNameIndex < tagIndex) { - slug = usesValue.substring(0, repoNameIndex > 0 ? repoNameIndex : tagIndex); - if (!isAction) { - final int beginIndex = usesValue.lastIndexOf("/") + 1; - tmpName = beginIndex >= tagIndex ? "InvalidAction" : usesValue.substring(beginIndex, tagIndex); - } else { - tmpSub = repoNameIndex < tagIndex && repoNameIndex > 0 ? "/" + usesValue.substring(repoNameIndex + 1, tagIndex) : ""; - tmpName = usesValue.substring(userNameIndex + 1, tagIndex); - } - } else { - tmpName = usesValue; - } - // END [EXTRACT PARTS] - + final GitHubActionCoordinates coordinates = GitHubActionCoordinates.of(isLocal, usesValue, absolutePath); return new GitHubAction() - .name(slug != null ? slug + ofNullable(tmpSub).orElse("") : tmpName) - .usesValue(usesValue) - .downloadUrl(isLocal ? absolutePath : toRemoteDownloadUrl(isAction, ref, slug, tmpSub, tmpName)) - .githubUrl(isAction ? toGitHubActionUrl(ref, slug, tmpSub) : toGitHubWorkflowUrl(ref, slug, tmpName)) + .name(coordinates.name()) + .usesValue(coordinates.usesValue()) + .downloadUrl(coordinates.downloadUrl()) + .githubUrl(coordinates.githubUrl()) .expiryTime(System.currentTimeMillis() + (CACHE_ONE_DAY * 14)) - .isLocal(isLocal) - .setAction(isAction) + .isLocal(coordinates.local()) + .setAction(coordinates.action()) .isSuppressed(false) ; } + private record GitHubActionCoordinates( + boolean local, + boolean action, + String name, + String usesValue, + String downloadUrl, + String githubUrl + ) { + + static GitHubActionCoordinates of(final boolean local, final String usesValue, final String absolutePath) { + final String safeUses = ofNullable(usesValue).orElse(""); + final String safePath = ofNullable(absolutePath).orElse(""); + final boolean action = local ? !isWorkflowFile(safeUses) : isRemoteActionPath(safePath); + final int tagIndex = safeUses.indexOf('@'); + final int ownerSeparator = safeUses.indexOf('/'); + final int repoSeparator = safeUses.indexOf('/', ownerSeparator + 1); + if (tagIndex == -1 || ownerSeparator >= tagIndex) { + return new GitHubActionCoordinates(local, action, safeUses, safeUses, local ? safePath : "", ""); + } + final String ref = safeUses.substring(tagIndex + 1); + final String slug = safeUses.substring(0, repoSeparator > 0 ? repoSeparator : tagIndex); + if (action) { + final String subPath = repoSeparator < tagIndex && repoSeparator > 0 ? "/" + safeUses.substring(repoSeparator + 1, tagIndex) : ""; + return new GitHubActionCoordinates( + local, + true, + slug + subPath, + safeUses, + local ? safePath : rawUrl(slug, ref, subPath + "/action.yml"), + local ? "" : "https://github.com/" + slug + "/tree/" + ref + subPath + "#readme" + ); + } + final int fileStart = safeUses.lastIndexOf('/') + 1; + final String workflowName = fileStart >= tagIndex ? "InvalidAction" : safeUses.substring(fileStart, tagIndex); + return new GitHubActionCoordinates( + local, + false, + slug, + safeUses, + local ? safePath : rawUrl(slug, ref, ".github/workflows/" + workflowName), + local ? "" : "https://github.com/" + slug + "/blob/" + ref + "/.github/workflows/" + workflowName + ); + } + + private static boolean isRemoteActionPath(final String absolutePath) { + return !absolutePath.contains(".yaml") && !absolutePath.contains(".yml") && !absolutePath.contains(".action.y"); + } + + private static boolean isWorkflowFile(final String usesValue) { + return ofNullable(usesValue) + .map(value -> value.replace('\\', '/')) + .filter(value -> value.contains(".github/workflows/") || value.contains(".gitea/workflows/")) + .filter(value -> value.endsWith(".yml") || value.endsWith(".yaml")) + .isPresent(); + } + + private static String rawUrl(final String slug, final String ref, final String path) { + return "https://raw.githubusercontent.com/" + slug + "/" + ref + "/" + path.replaceFirst("^/+", ""); + } + } + public Optional getLocalPath(final Project project) { return getLocalVirtualFile(project) .map(VirtualFile::getPath) - .or(() -> isLocal() ? ofNullable(downloadUrl()).filter(PsiElementHelper::hasText) : Optional.empty()); + .or(() -> isLocal() ? ofNullable(downloadUrl()).filter(WorkflowPsi::hasText) : Optional.empty()); } public Optional getLocalVirtualFile(final Project project) { @@ -144,7 +179,7 @@ public Map freshInputs() { if (isLocal()) { extractLocalParameters(); } - return concatMap(inputs, ignoredInputs.stream().filter(PsiElementHelper::hasText).collect(Collectors.toMap(key -> key, value -> "*** manual added input ***"))); + return concatMap(inputs, ignoredInputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.toMap(key -> key, value -> "*** manual added input ***"))); } public Map freshOutputs() { @@ -155,7 +190,7 @@ public Map freshOutputs(final boolean withIgnoredItems) { if (isLocal()) { extractLocalParameters(); } - return withIgnoredItems ? concatMap(outputs, ignoredOutputs.stream().filter(PsiElementHelper::hasText).collect(Collectors.toMap(key -> key, value -> "*** manual added output ***"))) : unmodifiableMap(outputs); + return withIgnoredItems ? concatMap(outputs, ignoredOutputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.toMap(key -> key, value -> "*** manual added output ***"))) : unmodifiableMap(outputs); } public Map freshSecrets() { @@ -179,7 +214,7 @@ public String displayName() { } public GitHubAction displayName(final String displayName) { - ofNullable(displayName).filter(PsiElementHelper::hasText).ifPresent(s -> metaData.put("displayName", s)); + ofNullable(displayName).filter(WorkflowPsi::hasText).ifPresent(s -> metaData.put("displayName", s)); return this; } @@ -188,7 +223,7 @@ public String description() { } public GitHubAction description(final String description) { - ofNullable(description).filter(PsiElementHelper::hasText).ifPresent(s -> metaData.put("description", s)); + ofNullable(description).filter(WorkflowPsi::hasText).ifPresent(s -> metaData.put("description", s)); return this; } @@ -270,7 +305,7 @@ public GitHubAction suppressInput(final String id, final boolean supress) { } else { ignoredInputs.remove(id); } - metaData.put("ignoredInputs", ignoredInputs.stream().filter(PsiElementHelper::hasText).collect(Collectors.joining(";"))); + metaData.put("ignoredInputs", ignoredInputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.joining(";"))); return this; } @@ -280,19 +315,19 @@ public GitHubAction suppressOutput(final String id, final boolean supress) { } else { ignoredOutputs.remove(id); } - metaData.put("ignoredOutputs", ignoredOutputs.stream().filter(PsiElementHelper::hasText).collect(Collectors.joining(";"))); + metaData.put("ignoredOutputs", ignoredOutputs.stream().filter(WorkflowPsi::hasText).collect(Collectors.joining(";"))); return this; } public Set ignoredInputs() { return ignoredInputs.stream() - .filter(PsiElementHelper::hasText) + .filter(WorkflowPsi::hasText) .collect(Collectors.toUnmodifiableSet()); } public Set ignoredOutputs() { return ignoredOutputs.stream() - .filter(PsiElementHelper::hasText) + .filter(WorkflowPsi::hasText) .collect(Collectors.toUnmodifiableSet()); } @@ -303,8 +338,8 @@ public Set ignoredOutputs() { */ public boolean hasSuppressedWarnings() { return isSuppressed() - || ignoredInputs.stream().anyMatch(PsiElementHelper::hasText) - || ignoredOutputs.stream().anyMatch(PsiElementHelper::hasText); + || ignoredInputs.stream().anyMatch(WorkflowPsi::hasText) + || ignoredOutputs.stream().anyMatch(WorkflowPsi::hasText); } /** @@ -355,8 +390,8 @@ public Map getMetaData() { public GitHubAction setMetaData(final Map metaData) { ofNullable(metaData).ifPresent(values -> { this.metaData.putAll(values); - this.ignoredInputs.addAll(Arrays.stream(values.getOrDefault("ignoredInputs", "").split(";")).filter(PsiElementHelper::hasText).toList()); - this.ignoredOutputs.addAll(Arrays.stream(values.getOrDefault("ignoredOutputs", "").split(";")).filter(PsiElementHelper::hasText).toList()); + this.ignoredInputs.addAll(Arrays.stream(values.getOrDefault("ignoredInputs", "").split(";")).filter(WorkflowPsi::hasText).toList()); + this.ignoredOutputs.addAll(Arrays.stream(values.getOrDefault("ignoredOutputs", "").split(";")).filter(WorkflowPsi::hasText).toList()); }); return this; } @@ -380,14 +415,6 @@ private static String actionYamlPath(final String localPath, final String fileNa return localPath.isBlank() ? fileName : localPath + "/" + fileName; } - private static boolean isWorkflowFile(final String usesValue) { - return ofNullable(usesValue) - .map(value -> value.replace('\\', '/')) - .filter(value -> value.contains(".github/workflows/") || value.contains(".gitea/workflows/")) - .filter(value -> value.endsWith(".yml") || value.endsWith(".yaml")) - .isPresent(); - } - private void extractParameters() { final boolean wasResolved = isResolved(); try { @@ -426,7 +453,7 @@ private void extractRemoteParameters() { } private void extractLocalParameters() { - of(downloadUrl()).flatMap(PsiElementHelper::toPath).filter(Files::isRegularFile).map(file -> { + of(downloadUrl()).flatMap(WorkflowPsi::toPath).filter(Files::isRegularFile).map(file -> { try { return Files.readString(file); } catch (final IOException ignored) { @@ -456,8 +483,8 @@ private static Optional readVirtualFileContent(final VirtualFile virtual private void setParameters(final String content) { isResolved(hasText(content)); readPsiElement(ProjectManager.getInstance().getDefaultProject(), downloadUrl(), content, psiFile -> { - displayName(PsiElementHelper.getText(psiFile.getContainingFile(), "name").orElse(name())); - description(PsiElementHelper.getText(psiFile.getContainingFile(), "description").orElse("")); + displayName(WorkflowPsi.getText(psiFile.getContainingFile(), "name").orElse(name())); + description(WorkflowPsi.getText(psiFile.getContainingFile(), "description").orElse("")); inputs.clear(); inputs.putAll(getActionParameters(psiFile, FIELD_INPUTS, isAction())); outputs.clear(); @@ -467,42 +494,15 @@ private void setParameters(final String content) { }); } - @Nullable - private static String toRemoteDownloadUrl(final boolean isAction, final String ref, final String slug, final String sub, final String name) { - return isAction ? toActionDownloadUrl(ref, slug, sub) : toWorkflowDownloadUrl(ref, slug, name); - } - - @Nullable - private static String toWorkflowDownloadUrl(final String ref, final String slug, final String name) { - return (ref != null && slug != null) ? "https://raw.githubusercontent.com/" + slug + "/" + ref + "/.github/workflows/" + name : null; - } - - @Nullable - private static String toActionDownloadUrl(final String ref, final String slug, final String sub) { - return (ref != null && slug != null && sub != null) ? "https://raw.githubusercontent.com/" + slug + "/" + ref + sub + "/action.yml" : null; - } - - @Nullable - private static String toGitHubWorkflowUrl(final String ref, final String slug, final String name) { - return (ref != null && slug != null) ? "https://github.com/" + slug + "/blob/" + ref + "/.github/workflows/" + name : null; - } - - @Nullable - private static String toGitHubActionUrl(final String ref, final String slug, final String sub) { - // return (ref != null && slug != null && sub != null) ? "https://github.com/" + slug + "/blob/" + ref + sub + "/action.yml" : null; - // https://github.com/actions/checkout/tree/Update-description#readme - return (ref != null && slug != null && sub != null) ? "https://github.com/" + slug + "/tree/" + ref + sub + "#readme" : null; - } - public List remoteRefs() { return Arrays.stream(metaData.getOrDefault("remoteRefs", "").split(";")) - .filter(PsiElementHelper::hasText) + .filter(WorkflowPsi::hasText) .toList(); } public GitHubAction remoteRefs(final List refs) { metaData.put("remoteRefs", ofNullable(refs).orElseGet(List::of).stream() - .filter(PsiElementHelper::hasText) + .filter(WorkflowPsi::hasText) .distinct() .collect(Collectors.joining(";"))); return this; @@ -510,28 +510,13 @@ public GitHubAction remoteRefs(final List refs) { @NotNull private static Map getActionParameters(final PsiElement psiElement, final String fieldName, final boolean action) { - if (action) { - return readActionParameters(psiElement, fieldName); - } else { - return readWorkflowParameters(psiElement, fieldName); - } - } - - @NotNull - private static Map readActionParameters(final PsiElement psiElement, final String fieldName) { - return getChild(psiElement.getContainingFile(), fieldName) - .map(PsiElementHelper::getChildren) - .map(children -> children.stream().collect(Collectors.toMap(YAMLKeyValue::getKeyText, field -> PsiElementHelper.getDescription(field, FIELD_INPUTS.equals(fieldName))))) - .orElseGet(Collections::emptyMap); - } - - @NotNull - private static Map readWorkflowParameters(final PsiElement psiElement, final String fieldName) { - return getChild(psiElement.getContainingFile(), FIELD_ON) - .flatMap(on -> getChild(on, "workflow_call")) - .flatMap(workflowCall -> getChild(workflowCall, fieldName)) - .map(PsiElementHelper::getChildren) - .map(children -> children.stream().collect(Collectors.toMap(YAMLKeyValue::getKeyText, field -> PsiElementHelper.getDescription(field, FIELD_INPUTS.equals(fieldName))))) + return (action + ? getChild(psiElement.getContainingFile(), fieldName) + : getChild(psiElement.getContainingFile(), FIELD_ON) + .flatMap(on -> getChild(on, "workflow_call")) + .flatMap(workflowCall -> getChild(workflowCall, fieldName))) + .map(WorkflowPsi::getChildren) + .map(children -> children.stream().collect(Collectors.toMap(YAMLKeyValue::getKeyText, field -> WorkflowPsi.getDescription(field, FIELD_INPUTS.equals(fieldName))))) .orElseGet(Collections::emptyMap); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java b/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java index 78966c4..88acf18 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/GitHubSchemaProvider.java @@ -1,6 +1,6 @@ package com.github.yunabraska.githubworkflow.model; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.intellij.json.JsonFileType; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.testFramework.LightVirtualFile; @@ -9,33 +9,24 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Objects; import java.util.Optional; -import java.util.Scanner; import java.util.function.Predicate; -import static java.util.Optional.ofNullable; - public class GitHubSchemaProvider implements JsonSchemaFileProvider { + private final String schemaName; private final String displayName; - private final VirtualFile schemaFile; private final Predicate validatePath; public GitHubSchemaProvider(final String schemaName, final String displayName, final Predicate validatePath) { + this.schemaName = schemaName; this.displayName = displayName; this.validatePath = validatePath; - - schemaFile = ofNullable(getClass().getResourceAsStream("/schemas/" + schemaName + ".json")) - .map(schemaStream -> { - try (final Scanner scanner = new Scanner(schemaStream, StandardCharsets.UTF_8)) { - final String schemaContent = scanner.useDelimiter("\\A").next(); - return new LightVirtualFile("github_workflow_plugin_" + schemaName + "_schema.json", JsonFileType.INSTANCE, schemaContent); - } - }) - .orElse(null); } @NotNull @@ -46,13 +37,13 @@ public String getName() { @Override public boolean isAvailable(@NotNull final VirtualFile file) { - return Optional.of(file).flatMap(PsiElementHelper::toPath).filter(validatePath).isPresent(); + return Optional.of(file).flatMap(WorkflowPsi::toPath).filter(validatePath).isPresent(); } @Nullable @Override public VirtualFile getSchemaFile() { - return schemaFile; + return new LightVirtualFile("github_workflow_plugin_" + schemaName + "_schema.json", JsonFileType.INSTANCE, schemaContent()); } @NotNull @@ -73,4 +64,12 @@ public boolean equals(final Object o) { public int hashCode() { return Objects.hash(getName()); } + + private String schemaContent() { + try (InputStream stream = getClass().getResourceAsStream("/schemas/" + schemaName + ".json")) { + return stream == null ? "{}" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (final IOException ignored) { + return "{}"; + } + } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/model/LocalActionReferenceResolver.java b/src/main/java/com/github/yunabraska/githubworkflow/model/LocalActionReferenceResolver.java index da76f68..467d2f7 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/LocalActionReferenceResolver.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/LocalActionReferenceResolver.java @@ -1,5 +1,7 @@ package com.github.yunabraska.githubworkflow.model; +import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; + import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; @@ -11,8 +13,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getProject; -import static com.github.yunabraska.githubworkflow.services.ReferenceContributor.ACTION_KEY; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getProject; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowReferences.ACTION_KEY; import static java.util.Optional.ofNullable; public class LocalActionReferenceResolver extends PsiReferenceBase implements PsiPolyVariantReference { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/model/SimpleElement.java b/src/main/java/com/github/yunabraska/githubworkflow/model/SimpleElement.java index e8492eb..3ac5c62 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/model/SimpleElement.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/model/SimpleElement.java @@ -1,6 +1,6 @@ package com.github.yunabraska.githubworkflow.model; -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; import com.intellij.codeInsight.lookup.LookupElement; import com.intellij.openapi.util.TextRange; @@ -10,7 +10,7 @@ import java.util.Objects; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.removeQuotes; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.removeQuotes; import static java.util.Optional.ofNullable; public record SimpleElement(String key, String text, TextRange range, NodeIcon icon) { @@ -45,7 +45,7 @@ public int endIndexOffset() { } public LookupElement toLookupElement() { - return GitHubWorkflowHelper.toLookupElement(icon, Character.MIN_VALUE, key, text); + return WorkflowYaml.toLookupElement(icon, Character.MIN_VALUE, key, text); } public static List completionItemsOf(final Map map, final NodeIcon icon) { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java new file mode 100644 index 0000000..cc54b9e --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRun.java @@ -0,0 +1,1047 @@ +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer; +import com.intellij.execution.lineMarker.RunLineMarkerContributor; +import com.intellij.execution.process.ProcessHandler; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.impl.source.tree.LeafPsiElement; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.yaml.psi.YAMLKeyValue; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.StringJoiner; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Stream; + +/** + * Small GitHub Actions REST client for workflow dispatch, status polling, cancellation, and logs. + */ +public class WorkflowRun { + + private static final String API_VERSION = "2026-03-10"; + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + private final HttpTransport transport; + private final AuthorizationProvider authorizationProvider; + private final ConcurrentMap successfulAuthorizations = new ConcurrentHashMap<>(); + + public WorkflowRun() { + this((Project) null); + } + + public WorkflowRun(final Project project) { + this(new JdkHttpTransport(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()), request -> RemoteActionProviders.Authorizations.forApiUrl(request.apiUrl(), request.tokenEnvVar(), project)); + } + + WorkflowRun(final HttpTransport transport) { + this(transport, request -> RemoteActionProviders.Authorizations.forApiUrl(request.apiUrl(), request.tokenEnvVar(), null)); + } + + WorkflowRun(final HttpTransport transport, final AuthorizationProvider authorizationProvider) { + this.transport = transport; + this.authorizationProvider = authorizationProvider; + } + + public DispatchResult dispatch(final Request request) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "POST", + workflowUrl(request) + "/dispatches", + dispatchBody(request), + "GitHub workflow dispatch" + ); + final JsonObject json = parseObject(response.body()); + return new DispatchResult( + longValue(json, "workflow_run_id").orElse(-1L), + stringValue(json, "run_url").orElse(""), + stringValue(json, "html_url").orElse("") + ); + } + + public RunStatus status(final Request request, final long runId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "GET", + runUrl(request, runId), + "", + "GitHub workflow status" + ); + final JsonObject json = parseObject(response.body()); + return runStatus(json, runId); + } + + public CancelResult cancel(final Request request, final long runId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "POST", + runUrl(request, runId) + "/cancel", + "", + "GitHub workflow cancel" + ); + return new CancelResult(response.statusCode(), accepted(response)); + } + + /** + * Requests GitHub to re-run a completed workflow run. + * + * @param request workflow repository and authorization context + * @param runId GitHub Actions run id + * @param failedOnly whether only failed jobs should be re-run + * @return HTTP status and whether GitHub accepted the re-run + * @throws IOException when GitHub rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ + public RerunResult rerun( + final Request request, + final long runId, + final boolean failedOnly + ) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "POST", + runUrl(request, runId) + (failedOnly ? "/rerun-failed-jobs" : "/rerun"), + "", + failedOnly ? "GitHub workflow failed jobs rerun" : "GitHub workflow rerun" + ); + return new RerunResult(response.statusCode(), accepted(response)); + } + + /** + * Deletes one completed workflow run from the remote repository. + * + * @param request workflow repository and authorization context + * @param runId GitHub Actions run id + * @return HTTP status and whether GitHub accepted the deletion + * @throws IOException when GitHub rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ + public DeleteResult delete(final Request request, final long runId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "DELETE", + runUrl(request, runId), + "", + "GitHub workflow run delete" + ); + return new DeleteResult(response.statusCode(), accepted(response)); + } + + public Optional latestRun(final Request request) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "GET", + workflowUrl(request) + "/runs?branch=" + encode(request.ref()) + "&event=workflow_dispatch&per_page=1", + "", + "GitHub workflow run discovery" + ); + final JsonObject json = parseObject(response.body()); + return objects(json, "workflow_runs") + .findFirst() + .map(run -> runStatus(run, -1L)) + .filter(run -> run.runId() >= 0); + } + + public String logs(final Request request, final long runId) throws IOException, InterruptedException { + final StringBuilder result = new StringBuilder(); + for (final JobStatus job : jobs(request, runId)) { + result.append("== ").append(job.name()).append(" [").append(job.status()).append(resultSuffix(job.conclusion())).append("]\n"); + final String logs = jobLogs(request, job.id()); + if (hasText(logs)) { + result.append(logs.stripTrailing()).append("\n"); + } + } + return result.toString(); + } + + /** + * Lists the artifacts produced by one workflow run. + * + * @param request workflow repository and authorization context + * @param runId GitHub Actions run id + * @return immutable list of artifacts known to GitHub for the run + * @throws IOException when GitHub rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ + public List artifacts(final Request request, final long runId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "GET", + runUrl(request, runId) + "/artifacts?per_page=100", + "", + "GitHub workflow artifacts" + ); + final JsonObject json = parseObject(response.body()); + return objects(json, "artifacts") + .map(WorkflowRun::artifactStatus) + .filter(artifact -> artifact.id() >= 0) + .toList(); + } + + /** + * Downloads one workflow artifact archive as bytes. + * + * @param request workflow repository and authorization context + * @param artifactId GitHub Actions artifact id + * @return zip archive bytes + * @throws IOException when GitHub rejects the request or the network call fails + * @throws InterruptedException when the IDE cancels the remote call + */ + public byte[] artifactZip(final Request request, final long artifactId) throws IOException, InterruptedException { + final HttpResponse response = sendBytes( + request, + "GET", + request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/artifacts/" + artifactId + "/zip", + "", + "GitHub workflow artifact download" + ); + return response.body(); + } + + public List jobs(final Request request, final long runId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "GET", + runUrl(request, runId) + "/jobs", + "", + "GitHub workflow jobs" + ); + final JsonObject json = parseObject(response.body()); + return objects(json, "jobs") + .map(WorkflowRun::jobStatus) + .filter(job -> job.id() >= 0) + .toList(); + } + + public String jobLogs(final Request request, final long jobId) throws IOException, InterruptedException { + final HttpResponse response = send( + request, + "GET", + request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/jobs/" + jobId + "/logs", + "", + "GitHub workflow job logs" + ); + return response.body(); + } + + private HttpResponse send( + final Request workflow, + final String method, + final String url, + final String body, + final String operation + ) throws IOException, InterruptedException { + return sendWithAuthorizations(workflow, method, url, body, operation, transport::send, WorkflowRun::failure, HttpResponse::body); + } + + private HttpResponse sendBytes( + final Request workflow, + final String method, + final String url, + final String body, + final String operation + ) throws IOException, InterruptedException { + return sendWithAuthorizations( + workflow, + method, + url, + body, + operation, + transport::sendBytes, + WorkflowRun::failureBytes, + response -> new String(Optional.ofNullable(response.body()).orElseGet(() -> new byte[0]), StandardCharsets.UTF_8) + ); + } + + private HttpResponse sendWithAuthorizations( + final Request workflow, + final String method, + final String url, + final String body, + final String operation, + final ResponseSender sender, + final BiFunction, WorkflowRunHttpException> failureFactory, + final Function, String> bodyText + ) throws IOException, InterruptedException { + WorkflowRunHttpException lastFailure = null; + boolean authenticatedRateLimitFailure = false; + final String authorizationCacheKey = authorizationCacheKey(workflow); + for (final RemoteActionProviders.Authorizations.Authorization authorization : authorizations(workflow, authorizationCacheKey)) { + if (!authorization.authenticated() && authenticatedRateLimitFailure) { + break; + } + final HttpResponse response = sender.send(request(workflow, method, url, body, authorization)); + if (accepted(response)) { + if (authorization.authenticated()) { + successfulAuthorizations.put(authorizationCacheKey, authorization); + } + return response; + } + lastFailure = failureFactory.apply(operation, response); + if (authorization.authenticated() && rateLimitExceeded(response.statusCode(), response.headers(), bodyText.apply(response))) { + authenticatedRateLimitFailure = true; + } + if (!shouldTryNextAuthorization(response.statusCode())) { + throw lastFailure; + } + } + throw lastFailure == null + ? new IOException(operation + " failed: no authorization candidates were available.") + : lastFailure; + } + + private static boolean accepted(final HttpResponse response) { + return response.statusCode() / 100 == 2; + } + + private List authorizations( + final Request workflow, + final String authorizationCacheKey + ) { + final List result = new ArrayList<>(); + Optional.ofNullable(successfulAuthorizations.get(authorizationCacheKey)).ifPresent(result::add); + final List authorizations = authorizationProvider.authorizations(workflow); + if (authorizations == null || authorizations.isEmpty()) { + result.add(RemoteActionProviders.Authorizations.Authorization.anonymous()); + } else { + result.addAll(authorizations); + } + return result.stream() + .filter(WorkflowRun::knownAuthorization) + .distinct() + .toList(); + } + + private static boolean knownAuthorization(final RemoteActionProviders.Authorizations.Authorization authorization) { + return authorization != null; + } + + private static HttpRequest request( + final Request workflow, + final String method, + final String url, + final String body, + final RemoteActionProviders.Authorizations.Authorization authorization + ) { + final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) + .timeout(TIMEOUT) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", API_VERSION) + .header("User-Agent", "GitHub-Workflow-Plugin"); + if (authorization.authenticated()) { + builder.header("Authorization", authorization.authorizationHeader()); + } + if ("POST".equals(method)) { + builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + } else if ("DELETE".equals(method)) { + builder.DELETE(); + } else { + builder.GET(); + } + return builder.build(); + } + + private static WorkflowRunHttpException failure(final String operation, final HttpResponse response) { + return failure(operation, response.statusCode(), response.headers(), response.body()); + } + + private static WorkflowRunHttpException failureBytes(final String operation, final HttpResponse response) { + final String body = new String(Optional.ofNullable(response.body()).orElseGet(() -> new byte[0]), StandardCharsets.UTF_8); + return failure(operation, response.statusCode(), response.headers(), body); + } + + private static WorkflowRunHttpException failure( + final String operation, + final int statusCode, + final HttpHeaders headers, + final String body + ) { + final boolean accountActionRecommended = needsAccountAction(statusCode, headers, body); + final String hint = accountActionRecommended + ? "\nAdd or refresh GitHub accounts in " + RemoteActionProviders.Authorizations.settingsHint() + "." + : ""; + final String summary = responseSummary(statusCode, headers, body); + return new WorkflowRunHttpException( + operation + " failed with HTTP " + statusCode + (summary.isEmpty() ? "" : ": " + summary) + hint, + statusCode, + body, + accountActionRecommended + ); + } + + private static String responseSummary(final HttpResponse response) { + return responseSummary(response.statusCode(), response.headers(), response.body()); + } + + private static String responseSummary(final int statusCode, final HttpHeaders headers, final String responseBody) { + final String body = Optional.ofNullable(responseBody).orElse("").strip(); + if (body.isEmpty()) { + return ""; + } + final String contentType = headers + .firstValue("Content-Type") + .orElse("") + .toLowerCase(); + if (contentType.contains("text/html") || body.startsWith(" value.toLowerCase(Locale.ROOT)) + .filter(value -> value.contains("rate limit")) + .isPresent(); + } + + private static boolean needsAccountAction(final HttpResponse response) { + return needsAccountAction(response.statusCode(), response.headers(), response.body()); + } + + private static boolean needsAccountAction(final int statusCode, final HttpHeaders headers, final String body) { + if (statusCode == 401 || statusCode == 429) { + return true; + } + if (statusCode != 403) { + return false; + } + return !mustHaveAdminRights(body) || rateLimitExceeded(statusCode, headers, body); + } + + private static boolean mustHaveAdminRights(final String body) { + return Optional.ofNullable(body) + .map(value -> value.toLowerCase(Locale.ROOT)) + .filter(value -> value.contains("must have admin rights")) + .isPresent(); + } + + private static String workflowUrl(final Request request) { + return request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/workflows/" + encode(workflowId(request.workflowPath())); + } + + private static String authorizationCacheKey(final Request request) { + return Optional.ofNullable(request.apiUrl()).orElse("") + "|" + Optional.ofNullable(request.tokenEnvVar()).orElse(""); + } + + private static String runUrl(final Request request, final long runId) { + return request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/runs/" + runId; + } + + private static String workflowId(final String workflowPath) { + final String normalized = Optional.ofNullable(workflowPath).orElse("").replace('\\', '/'); + final int slash = normalized.lastIndexOf('/'); + return slash < 0 ? normalized : normalized.substring(slash + 1); + } + + private static String dispatchBody(final Request request) { + final StringJoiner inputs = new StringJoiner(","); + request.inputs().entrySet().stream() + .filter(entry -> hasText(entry.getKey())) + .limit(25) + .forEach(entry -> inputs.add(quote(entry.getKey()) + ":" + quote(entry.getValue()))); + final String inputsJson = inputs.length() == 0 ? "" : ",\"inputs\":{" + inputs + "}"; + return "{\"ref\":" + quote(request.ref()) + inputsJson + "}"; + } + + private static String resultSuffix(final String conclusion) { + return hasText(conclusion) ? "/" + conclusion : ""; + } + + private static JsonObject parseObject(final String body) { + if (!hasText(body)) { + return new JsonObject(); + } + final JsonElement element = JsonParser.parseString(body); + return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); + } + + private static Stream objects(final JsonObject object, final String name) { + return Optional.ofNullable(object.get(name)) + .filter(JsonElement::isJsonArray) + .stream() + .flatMap(elements -> java.util.stream.StreamSupport.stream(elements.getAsJsonArray().spliterator(), false)) + .filter(JsonElement::isJsonObject) + .map(JsonElement::getAsJsonObject); + } + + private static RunStatus runStatus(final JsonObject object, final long fallbackRunId) { + return new RunStatus( + longValue(object, "id").orElse(fallbackRunId), + stringValue(object, "status").orElse("unknown"), + stringValue(object, "conclusion").orElse(""), + stringValue(object, "html_url").orElse("") + ); + } + + private static JobStatus jobStatus(final JsonObject object) { + return new JobStatus( + longValue(object, "id").orElse(-1L), + stringValue(object, "name").orElse("job"), + stringValue(object, "status").orElse("unknown"), + stringValue(object, "conclusion").orElse(""), + stringValue(object, "html_url").orElse("") + ); + } + + private static ArtifactStatus artifactStatus(final JsonObject object) { + return new ArtifactStatus( + longValue(object, "id").orElse(-1L), + stringValue(object, "name").orElse("artifact"), + longValue(object, "size_in_bytes").orElse(0L), + booleanValue(object, "expired").orElse(false), + stringValue(object, "archive_download_url").orElse("") + ); + } + + private static Optional stringValue(final JsonObject object, final String name) { + return Optional.ofNullable(object.get(name)) + .filter(JsonElement::isJsonPrimitive) + .map(JsonElement::getAsString) + .filter(WorkflowRun::hasText); + } + + private static Optional longValue(final JsonObject object, final String name) { + return Optional.ofNullable(object.get(name)) + .filter(JsonElement::isJsonPrimitive) + .map(value -> { + try { + return value.getAsLong(); + } catch (final NumberFormatException ignored) { + return -1L; + } + }) + .filter(value -> value >= 0); + } + + private static Optional booleanValue(final JsonObject object, final String name) { + return Optional.ofNullable(object.get(name)) + .filter(JsonElement::isJsonPrimitive) + .map(JsonElement::getAsBoolean); + } + + private static String encode(final String value) { + return URLEncoder.encode(Optional.ofNullable(value).orElse(""), StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static String quote(final String value) { + return "\"" + Optional.ofNullable(value).orElse("") + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + "\""; + } + + private static boolean hasText(final String value) { + return value != null && !value.isBlank(); + } + + interface HttpTransport { + HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + + default HttpResponse sendBytes(final HttpRequest request) throws IOException, InterruptedException { + throw new IOException("Binary transport is not available."); + } + } + + interface AuthorizationProvider { + List authorizations(Request request); + } + + private interface ResponseSender { + HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + } + + private record JdkHttpTransport(HttpClient client) implements HttpTransport { + @Override + public HttpResponse send(final HttpRequest request) throws IOException, InterruptedException { + return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } + + @Override + public HttpResponse sendBytes(final HttpRequest request) throws IOException, InterruptedException { + return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } + } + + /** + * Request data needed to dispatch and observe one GitHub Actions workflow run. + * + * @param apiUrl GitHub REST API base URL + * @param owner repository owner + * @param repo repository name + * @param workflowPath workflow file path or file name + * @param ref branch or tag used for workflow dispatch + * @param inputs workflow_dispatch input values + * @param tokenEnvVar optional environment variable used only after IDE GitHub accounts fail or are unavailable + */ + public record Request( + String apiUrl, + String owner, + String repo, + String workflowPath, + String ref, + Map inputs, + String tokenEnvVar + ) { + + public Request { + inputs = Map.copyOf(inputs == null ? Map.of() : inputs); + } + + public String repositorySlug() { + return owner + "/" + repo; + } + } + + public static class DispatchInputs { + + public List parse(final String yaml) { + final List lines = lines(yaml); + final Optional workflowDispatchIndex = workflowDispatchIndex(lines); + if (workflowDispatchIndex.isEmpty()) { + return List.of(); + } + final int workflowDispatchIndent = lines.get(workflowDispatchIndex.get()).indent(); + final Optional inputsIndex = childIndex(lines, workflowDispatchIndex.get() + 1, workflowDispatchIndent, "inputs"); + if (inputsIndex.isEmpty()) { + return List.of(); + } + final int inputsIndent = lines.get(inputsIndex.get()).indent(); + final List result = new ArrayList<>(); + for (int index = inputsIndex.get() + 1; index < lines.size(); index++) { + final Line line = lines.get(index); + if (line.indent() <= inputsIndent) { + break; + } + if (line.indent() == inputsIndent + 2 && line.keyValue().isPresent()) { + result.add(readInput(lines, index, inputsIndent + 2)); + } + } + return List.copyOf(result); + } + + public boolean hasWorkflowDispatch(final String yaml) { + return workflowDispatchIndex(lines(yaml)).isPresent(); + } + + public String defaultsText(final String yaml) { + final StringBuilder result = new StringBuilder(); + for (final Input input : parse(yaml)) { + result.append(input.name()).append("=").append(input.defaultValue()).append("\n"); + } + return result.toString(); + } + + public static Map parseKeyValueText(final String text) { + final java.util.LinkedHashMap result = new java.util.LinkedHashMap<>(); + Optional.ofNullable(text).orElse("").lines() + .map(String::trim) + .filter(line -> !line.isBlank()) + .filter(line -> !line.startsWith("#")) + .forEach(line -> { + final int separator = line.indexOf('='); + if (separator > 0) { + result.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); + } + }); + return Map.copyOf(result); + } + + private static Input readInput(final List lines, final int inputIndex, final int inputIndent) { + final String name = lines.get(inputIndex).keyValue().orElse(""); + String type = "string"; + String required = "false"; + String defaultValue = ""; + String description = ""; + final List options = new ArrayList<>(); + for (int index = inputIndex + 1; index < lines.size(); index++) { + final Line line = lines.get(index); + if (line.indent() <= inputIndent) { + break; + } + if (line.indent() == inputIndent + 2) { + if ("type".equals(line.keyValue().orElse(""))) { + type = line.value(); + } else if ("required".equals(line.keyValue().orElse(""))) { + required = line.value(); + } else if ("default".equals(line.keyValue().orElse(""))) { + defaultValue = line.value(); + } else if ("description".equals(line.keyValue().orElse(""))) { + description = line.value(); + } else if ("options".equals(line.keyValue().orElse(""))) { + options.addAll(readOptions(lines, index, inputIndent + 2)); + } + } + } + return new Input(name, type, Boolean.parseBoolean(required), defaultValue, description, List.copyOf(options)); + } + + private static List readOptions(final List lines, final int optionsIndex, final int optionsIndent) { + final List result = new ArrayList<>(inlineOptions(lines.get(optionsIndex).value())); + for (int index = optionsIndex + 1; index < lines.size(); index++) { + final Line line = lines.get(index); + if (line.indent() <= optionsIndent) { + break; + } + if (line.content().startsWith("- ")) { + result.add(stripQuotes(line.content().substring(2).trim())); + } + } + return List.copyOf(result); + } + + private static List inlineOptions(final String value) { + final String trimmed = value == null ? "" : value.trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { + return List.of(); + } + final String body = trimmed.substring(1, trimmed.length() - 1); + if (body.isBlank()) { + return List.of(); + } + return splitInlineList(body).stream() + .filter(option -> !option.isBlank()) + .map(DispatchInputs::stripQuotes) + .toList(); + } + + private static List splitInlineList(final String body) { + final List result = new ArrayList<>(); + final StringBuilder current = new StringBuilder(); + char quote = 0; + for (int index = 0; index < body.length(); index++) { + final char character = body.charAt(index); + if (quote != 0) { + current.append(character); + if (character == quote) { + quote = 0; + } + } else if (character == '\'' || character == '"') { + quote = character; + current.append(character); + } else if (character == ',') { + result.add(current.toString().trim()); + current.setLength(0); + } else { + current.append(character); + } + } + result.add(current.toString().trim()); + return List.copyOf(result); + } + + private static Optional workflowDispatchIndex(final List lines) { + for (int index = 0; index < lines.size(); index++) { + final Line line = lines.get(index); + if ("workflow_dispatch".equals(line.keyValue().orElse("")) + || "on".equals(line.keyValue().orElse("")) && "workflow_dispatch".equals(line.value())) { + return Optional.of(index); + } + if (line.content().equals("- workflow_dispatch")) { + return Optional.of(index); + } + } + return Optional.empty(); + } + + private static Optional childIndex(final List lines, final int start, final int parentIndent, final String key) { + for (int index = start; index < lines.size(); index++) { + final Line line = lines.get(index); + if (line.indent() <= parentIndent) { + break; + } + if (key.equals(line.keyValue().orElse(""))) { + return Optional.of(index); + } + } + return Optional.empty(); + } + + private static List lines(final String yaml) { + final List result = new ArrayList<>(); + Optional.ofNullable(yaml).orElse("").lines() + .map(DispatchInputs::line) + .filter(line -> !line.content().isBlank()) + .filter(line -> !line.content().startsWith("#")) + .forEach(result::add); + return result; + } + + private static Line line(final String raw) { + int indent = 0; + while (indent < raw.length() && raw.charAt(indent) == ' ') { + indent++; + } + final String content = raw.substring(indent).trim(); + final int separator = content.indexOf(':'); + if (separator < 0) { + return new Line(indent, content, "", ""); + } + final String key = content.substring(0, separator).trim(); + final String value = stripQuotes(content.substring(separator + 1).trim()); + return new Line(indent, content, key, value); + } + + private static String stripQuotes(final String value) { + if (value.length() >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) { + return value.substring(1, value.length() - 1); + } + return value; + } + + public record Input(String name, String type, boolean required, String defaultValue, String description, List options) { + public Input( + final String name, + final String type, + final boolean required, + final String defaultValue, + final String description + ) { + this(name, type, required, defaultValue, description, List.of()); + } + + public Input { + options = options == null ? List.of() : List.copyOf(options); + } + } + + private record Line(int indent, String content, String key, String value) { + Optional keyValue() { + return key.isBlank() ? Optional.empty() : Optional.of(key); + } + } + } + + /** + * Tracks workflow runs started from one project so editor gutter actions can switch between run and stop. + */ + @Service(Service.Level.PROJECT) + public static class Tracker { + + private final Project project; + private final ConcurrentMap runs = new ConcurrentHashMap<>(); + + public Tracker(@NotNull final Project project) { + this.project = project; + } + + public static Tracker getInstance(final Project project) { + return project.getService(Tracker.class); + } + + public static String key(final String workflowPath) { + return Optional.ofNullable(workflowPath).orElse("").replace('\\', '/'); + } + + public boolean isRunning(final String workflowPath) { + return runs.containsKey(key(workflowPath)); + } + + public void register(final String workflowPath, final ProcessHandler processHandler) { + runs.put(key(workflowPath), processHandler); + refreshGutters(); + } + + public void unregister(final String workflowPath, final ProcessHandler processHandler) { + runs.remove(key(workflowPath), processHandler); + refreshGutters(); + } + + public boolean stop(final String workflowPath) { + return Optional.ofNullable(runs.get(key(workflowPath))) + .map(processHandler -> { + processHandler.destroyProcess(); + return true; + }) + .orElse(false); + } + + private void refreshGutters() { + ApplicationManager.getApplication().invokeLater(() -> { + if (!project.isDisposed()) { + DaemonCodeAnalyzer.getInstance(project).settingsChanged(); + } + }); + } + } + + public static class LineMarkerContributor extends RunLineMarkerContributor { + + private static final RepositoryAvailability DEFAULT_REPOSITORY_AVAILABILITY = + (project, file) -> new WorkflowLocation.RepositoryResolver().resolve(project, file).isPresent(); + private static final AtomicReference repositoryAvailability = + new AtomicReference<>(DEFAULT_REPOSITORY_AVAILABILITY); + + @Override + public @Nullable Info getInfo(final PsiElement element) { + if (!(element instanceof LeafPsiElement) || !"workflow_dispatch".equals(element.getText())) { + return null; + } + if (!(element.getParent() instanceof YAMLKeyValue keyValue) || !"workflow_dispatch".equals(keyValue.getKeyText())) { + return null; + } + final Optional workflowPath = Optional.ofNullable(element.getContainingFile()) + .map(file -> file.getVirtualFile()) + .flatMap(file -> WorkflowRunConfiguration.Producer.workflowPath(element.getProject(), file) + .or(() -> WorkflowPsi.toPath(file).map(path -> path.getFileName().toString()))); + final boolean workflowFile = Optional.ofNullable(element.getContainingFile()) + .map(file -> file.getVirtualFile()) + .flatMap(WorkflowPsi::toPath) + .filter(WorkflowYaml::isWorkflowPath) + .isPresent(); + if (!workflowFile || workflowPath.isEmpty()) { + return null; + } + if (Tracker.getInstance(element.getProject()).isRunning(workflowPath.get())) { + return new Info( + AllIcons.Actions.Suspend, + new AnAction[]{new StopWorkflowRunAction(workflowPath.get())}, + item -> GitHubWorkflowBundle.message("workflow.run.gutter.stop") + ); + } + final boolean repositoryAvailable = Optional.ofNullable(element.getContainingFile()) + .map(file -> file.getVirtualFile()) + .map(file -> repositoryAvailability.get().available(element.getProject(), file)) + .orElse(false); + return repositoryAvailable ? withExecutorActions(AllIcons.Actions.Execute) : null; + } + + static RepositoryAvailability useRepositoryAvailabilityForTests(final RepositoryAvailability availability) { + return repositoryAvailability.getAndSet(availability == null ? DEFAULT_REPOSITORY_AVAILABILITY : availability); + } + + interface RepositoryAvailability { + boolean available(Project project, VirtualFile file); + } + } + + private static class StopWorkflowRunAction extends AnAction { + + private final String workflowPath; + + private StopWorkflowRunAction(final String workflowPath) { + super( + GitHubWorkflowBundle.message("workflow.run.gutter.stop.text"), + GitHubWorkflowBundle.message("workflow.run.gutter.stop.description"), + AllIcons.Actions.Suspend + ); + this.workflowPath = workflowPath; + } + + @Override + public void actionPerformed(@NotNull final AnActionEvent event) { + Optional.ofNullable(event.getProject()) + .map(Tracker::getInstance) + .ifPresent(tracker -> tracker.stop(workflowPath)); + } + } + + public record DispatchResult(long runId, String runUrl, String htmlUrl) { + } + + public record RunStatus(long runId, String status, String conclusion, String htmlUrl) { + public boolean completed() { + return "completed".equals(status); + } + } + + public record CancelResult(int statusCode, boolean accepted) { + } + + public record RerunResult(int statusCode, boolean accepted) { + } + + public record DeleteResult(int statusCode, boolean accepted) { + } + + public record JobStatus(long id, String name, String status, String conclusion, String htmlUrl) { + } + + public record ArtifactStatus(long id, String name, long sizeInBytes, boolean expired, String archiveDownloadUrl) { + } + + public static class WorkflowRunHttpException extends IOException { + + private final int statusCode; + private final String body; + private final boolean accountActionRecommended; + + public WorkflowRunHttpException( + final String message, + final int statusCode, + final String body, + final boolean accountActionRecommended + ) { + super(message); + this.statusCode = statusCode; + this.body = body; + this.accountActionRecommended = accountActionRecommended; + } + + public int statusCode() { + return statusCode; + } + + public String body() { + return body; + } + + public boolean accountActionRecommended() { + return accountActionRecommended; + } + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfiguration.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfiguration.java new file mode 100644 index 0000000..248cb88 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfiguration.java @@ -0,0 +1,461 @@ +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.intellij.execution.ExecutionException; +import com.intellij.execution.Executor; +import com.intellij.execution.actions.ConfigurationContext; +import com.intellij.execution.actions.LazyRunConfigurationProducer; +import com.intellij.execution.configurations.CommandLineState; +import com.intellij.execution.configurations.ConfigurationFactory; +import com.intellij.execution.configurations.ConfigurationType; +import com.intellij.execution.configurations.ConfigurationTypeUtil; +import com.intellij.execution.configurations.RunConfiguration; +import com.intellij.execution.configurations.RunConfigurationBase; +import com.intellij.execution.configurations.RunProfileState; +import com.intellij.execution.configurations.RuntimeConfigurationError; +import com.intellij.execution.configurations.RuntimeConfigurationException; +import com.intellij.execution.process.ProcessHandler; +import com.intellij.execution.runners.ExecutionEnvironment; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SettingsEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.InvalidDataException; +import com.intellij.openapi.util.Ref; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.ui.ToolbarDecorator; +import com.intellij.ui.table.JBTable; +import org.jdom.Element; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.BorderFactory; +import javax.swing.Icon; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.table.DefaultTableModel; +import java.awt.BorderLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Run configuration that dispatches a workflow_dispatch event and follows the resulting run. + */ +public class WorkflowRunConfiguration extends RunConfigurationBase { + + private String apiUrl = "https://api.github.com"; + private String owner = ""; + private String repo = ""; + private String workflowPath = ""; + private String ref = "main"; + private String tokenEnvVar = ""; + private String inputsText = ""; + + WorkflowRunConfiguration(final Project project, final ConfigurationFactory factory, final String name) { + super(project, factory, name); + } + + @Override + public @NotNull SettingsEditor getConfigurationEditor() { + return new Editor(); + } + + @Override + public @Nullable RunProfileState getState(@NotNull final Executor executor, @NotNull final ExecutionEnvironment environment) { + return new CommandLineState(environment) { + @Override + protected @NotNull ProcessHandler startProcess() throws ExecutionException { + return new WorkflowRunProcessHandler(getProject(), toRequest(), new WorkflowRun(getProject()), environment.getExecutor()); + } + }; + } + + @Override + public void checkConfiguration() throws RuntimeConfigurationException { + if (isBlank(apiUrl)) { + throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.apiUrl")); + } + if (isBlank(owner) || isBlank(repo)) { + throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.repository")); + } + if (isBlank(workflowPath)) { + throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.workflow")); + } + if (isBlank(ref)) { + throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.ref")); + } + if (WorkflowRun.DispatchInputs.parseKeyValueText(inputsText).size() > 25) { + throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.inputs")); + } + } + + @Override + public void readExternal(@NotNull final Element element) throws InvalidDataException { + super.readExternal(element); + apiUrl = value(element, "apiUrl", apiUrl); + owner = value(element, "owner", owner); + repo = value(element, "repo", repo); + workflowPath = value(element, "workflowPath", workflowPath); + ref = value(element, "ref", ref); + tokenEnvVar = value(element, "tokenEnvVar", tokenEnvVar); + inputsText = value(element, "inputsText", inputsText); + } + + @Override + public void writeExternal(@NotNull final Element element) { + super.writeExternal(element); + element.setAttribute("apiUrl", apiUrl); + element.setAttribute("owner", owner); + element.setAttribute("repo", repo); + element.setAttribute("workflowPath", workflowPath); + element.setAttribute("ref", ref); + element.setAttribute("tokenEnvVar", tokenEnvVar); + element.setAttribute("inputsText", inputsText); + } + + WorkflowRun.Request toRequest() { + final Map inputs = WorkflowRun.DispatchInputs.parseKeyValueText(inputsText); + return new WorkflowRun.Request(apiUrl, owner, repo, workflowPath, ref, inputs, tokenEnvVar); + } + + public String apiUrl() { + return apiUrl; + } + + public WorkflowRunConfiguration apiUrl(final String apiUrl) { + this.apiUrl = clean(apiUrl); + return this; + } + + public String owner() { + return owner; + } + + public WorkflowRunConfiguration owner(final String owner) { + this.owner = clean(owner); + return this; + } + + public String repo() { + return repo; + } + + public WorkflowRunConfiguration repo(final String repo) { + this.repo = clean(repo); + return this; + } + + public String workflowPath() { + return workflowPath; + } + + public WorkflowRunConfiguration workflowPath(final String workflowPath) { + this.workflowPath = clean(workflowPath); + return this; + } + + public String ref() { + return ref; + } + + public WorkflowRunConfiguration ref(final String ref) { + this.ref = clean(ref); + return this; + } + + public String tokenEnvVar() { + return tokenEnvVar; + } + + public WorkflowRunConfiguration tokenEnvVar(final String tokenEnvVar) { + this.tokenEnvVar = clean(tokenEnvVar); + return this; + } + + public String inputsText() { + return inputsText; + } + + public WorkflowRunConfiguration inputsText(final String inputsText) { + this.inputsText = inputsText == null ? "" : inputsText; + return this; + } + + private static String value(final Element element, final String name, final String fallback) { + final String value = element.getAttributeValue(name); + return value == null ? fallback : value; + } + + private static String clean(final String value) { + return value == null ? "" : value.trim(); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } + + public static class Editor extends SettingsEditor { + + private final JPanel panel = new JPanel(new BorderLayout(8, 8)); + private final JTextField apiUrl = new JTextField(); + private final JTextField owner = new JTextField(); + private final JTextField repo = new JTextField(); + private final JTextField workflowPath = new JTextField(); + private final JTextField ref = new JTextField(); + private final JTextField tokenEnvVar = new JTextField(); + private final JPanel inputPanel = new JPanel(new BorderLayout(4, 4)); + private final DefaultTableModel inputsModel = new DefaultTableModel(new Object[][]{}, new Object[]{ + GitHubWorkflowBundle.message("documentation.name.label"), + GitHubWorkflowBundle.message("documentation.value.label") + }) { + @Override + public boolean isCellEditable(final int row, final int column) { + return true; + } + }; + private final JBTable inputsTable = new JBTable(inputsModel); + + public Editor() { + final JPanel fields = new JPanel(new GridBagLayout()); + fields.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8)); + addRow(fields, 0, GitHubWorkflowBundle.message("workflow.run.field.apiUrl"), apiUrl); + addRow(fields, 1, GitHubWorkflowBundle.message("workflow.run.field.owner"), owner); + addRow(fields, 2, GitHubWorkflowBundle.message("workflow.run.field.repo"), repo); + addRow(fields, 3, GitHubWorkflowBundle.message("workflow.run.field.workflow"), workflowPath); + addRow(fields, 4, GitHubWorkflowBundle.message("workflow.run.field.ref"), ref); + addRow(fields, 5, GitHubWorkflowBundle.message("workflow.run.field.tokenEnv"), tokenEnvVar); + panel.add(fields, BorderLayout.NORTH); + + inputsTable.setFillsViewportHeight(true); + inputPanel.setBorder(BorderFactory.createTitledBorder(GitHubWorkflowBundle.message("workflow.run.inputs.title"))); + inputPanel.add(ToolbarDecorator.createDecorator(inputsTable) + .setAddAction(button -> addInputRow("", "")) + .setRemoveAction(button -> removeSelectedInputRows()) + .disableUpDownActions() + .createPanel(), BorderLayout.CENTER); + panel.add(inputPanel, BorderLayout.CENTER); + } + + @Override + protected void resetEditorFrom(@NotNull final WorkflowRunConfiguration configuration) { + apiUrl.setText(configuration.apiUrl()); + owner.setText(configuration.owner()); + repo.setText(configuration.repo()); + workflowPath.setText(configuration.workflowPath()); + ref.setText(configuration.ref()); + tokenEnvVar.setText(configuration.tokenEnvVar()); + resetInputs(configuration); + } + + @Override + protected void applyEditorTo(@NotNull final WorkflowRunConfiguration configuration) throws ConfigurationException { + configuration.apiUrl(apiUrl.getText()) + .owner(owner.getText()) + .repo(repo.getText()) + .workflowPath(workflowPath.getText()) + .ref(ref.getText()) + .tokenEnvVar(tokenEnvVar.getText()) + .inputsText(inputsText()); + } + + @Override + protected @NotNull JComponent createEditor() { + return panel; + } + + private static void addRow(final JPanel panel, final int row, final String label, final JTextField field) { + final GridBagConstraints labelConstraints = new GridBagConstraints(); + labelConstraints.gridx = 0; + labelConstraints.gridy = row; + labelConstraints.anchor = GridBagConstraints.WEST; + labelConstraints.insets = new Insets(2, 0, 2, 8); + panel.add(new JLabel(label), labelConstraints); + + final GridBagConstraints fieldConstraints = new GridBagConstraints(); + fieldConstraints.gridx = 1; + fieldConstraints.gridy = row; + fieldConstraints.weightx = 1; + fieldConstraints.fill = GridBagConstraints.HORIZONTAL; + fieldConstraints.insets = new Insets(2, 0, 2, 0); + panel.add(field, fieldConstraints); + } + + private void resetInputs(final WorkflowRunConfiguration configuration) { + inputsModel.setRowCount(0); + for (final Map.Entry entry : WorkflowRun.DispatchInputs.parseKeyValueText(configuration.inputsText()).entrySet()) { + addInputRow(entry.getKey(), entry.getValue()); + } + } + + private void addInputRow(final String key, final String value) { + inputsModel.addRow(new Object[]{key, value}); + } + + private void removeSelectedInputRows() { + final int[] selectedRows = inputsTable.getSelectedRows(); + for (int index = selectedRows.length - 1; index >= 0; index--) { + inputsModel.removeRow(inputsTable.convertRowIndexToModel(selectedRows[index])); + } + } + + private String inputsText() { + if (inputsTable.isEditing() && inputsTable.getCellEditor() != null) { + inputsTable.getCellEditor().stopCellEditing(); + } + final StringBuilder result = new StringBuilder(); + for (int row = 0; row < inputsModel.getRowCount(); row++) { + final String key = Objects.toString(inputsModel.getValueAt(row, 0), "").trim(); + if (!key.isBlank()) { + final String value = Objects.toString(inputsModel.getValueAt(row, 1), ""); + result.append(key).append("=").append(value).append("\n"); + } + } + return result.toString(); + } + } + + public static class Producer extends LazyRunConfigurationProducer { + + private static final WorkflowRun.DispatchInputs DISPATCH_INPUTS = new WorkflowRun.DispatchInputs(); + + @Override + public @NotNull ConfigurationFactory getConfigurationFactory() { + return Type.getInstance().factory(); + } + + @Override + protected boolean setupConfigurationFromContext( + @NotNull final WorkflowRunConfiguration configuration, + @NotNull final ConfigurationContext context, + @NotNull final Ref sourceElement + ) { + final PsiFile file = workflowFile(context.getPsiLocation()).orElse(null); + if (file == null) { + return false; + } + final Project project = context.getProject(); + final WorkflowLocation.RepositoryResolver repositoryResolver = new WorkflowLocation.RepositoryResolver(); + final WorkflowLocation.Repository repository = repositoryResolver.resolve(project, file.getVirtualFile()).orElse(null); + if (repository == null) { + return false; + } + final String path = workflowPath(project, file.getVirtualFile()).orElse(file.getName()); + configuration.setName(GitHubWorkflowBundle.message("workflow.run.configuration.name", file.getName())); + configuration.apiUrl(repository.apiUrl()) + .owner(repository.owner()) + .repo(repository.repo()) + .workflowPath(path) + .ref(repositoryResolver.branch(project, file.getVirtualFile()).orElse("main")) + .tokenEnvVar("") + .inputsText(DISPATCH_INPUTS.defaultsText(file.getText())); + sourceElement.set(file); + return true; + } + + @Override + public boolean isConfigurationFromContext( + @NotNull final WorkflowRunConfiguration configuration, + @NotNull final ConfigurationContext context + ) { + return workflowFile(context.getPsiLocation()) + .flatMap(file -> workflowPath(context.getProject(), file.getVirtualFile())) + .filter(path -> path.equals(configuration.workflowPath())) + .filter(path -> new WorkflowLocation.RepositoryResolver().branch(context.getProject()) + .map(branch -> branch.equals(configuration.ref())) + .orElse(true)) + .isPresent(); + } + + private static Optional workflowFile(final PsiElement element) { + return Optional.ofNullable(element) + .map(PsiElement::getContainingFile) + .filter(file -> Optional.ofNullable(file.getVirtualFile()) + .flatMap(WorkflowPsi::toPath) + .filter(WorkflowYaml::isWorkflowPath) + .isPresent()); + } + + static Optional workflowPath(final Project project, final VirtualFile file) { + return Optional.ofNullable(project) + .flatMap(p -> Optional.ofNullable(com.intellij.openapi.project.ProjectUtil.guessProjectDir(p))) + .map(VirtualFile::getPath) + .map(Path::of) + .flatMap(root -> Optional.ofNullable(file) + .map(VirtualFile::getPath) + .map(Path::of) + .map(root::relativize) + .map(Path::toString) + .map(path -> path.replace('\\', '/'))); + } + } + + public static class Type implements ConfigurationType { + + public static final String ID = "GitHubWorkflow.RunConfiguration"; + + private final ConfigurationFactory factory = new Factory(this); + + public static Type getInstance() { + return ConfigurationTypeUtil.findConfigurationType(Type.class); + } + + @Override + public String getDisplayName() { + return GitHubWorkflowBundle.message("workflow.run.configuration.display"); + } + + @Override + public String getConfigurationTypeDescription() { + return GitHubWorkflowBundle.message("workflow.run.configuration.description"); + } + + @Override + public Icon getIcon() { + return AllIcons.Actions.Execute; + } + + @Override + public @NotNull String getId() { + return ID; + } + + @Override + public ConfigurationFactory[] getConfigurationFactories() { + return new ConfigurationFactory[]{factory}; + } + + public ConfigurationFactory factory() { + return factory; + } + + private static class Factory extends ConfigurationFactory { + private Factory(final ConfigurationType type) { + super(type); + } + + @Override + public @NotNull String getId() { + return ID + ".Factory"; + } + + @Override + public RunConfiguration createTemplateConfiguration(@NotNull final Project project) { + return new WorkflowRunConfiguration(project, this, GitHubWorkflowBundle.message("workflow.run.configuration.display")); + } + } + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandler.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java similarity index 56% rename from src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandler.java rename to src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java index 1c8fef3..3d752ef 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandler.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandler.java @@ -1,4 +1,8 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.notification.NotificationAction; import com.intellij.notification.NotificationGroupManager; @@ -6,19 +10,25 @@ import com.intellij.execution.Executor; import com.intellij.execution.process.ProcessHandler; import com.intellij.execution.process.ProcessOutputTypes; +import com.intellij.ide.actions.RevealFileAction; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.options.ShowSettingsUtil; +import com.intellij.openapi.application.PathManager; import com.intellij.openapi.project.Project; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -31,13 +41,13 @@ /** * IDE process facade that dispatches, polls, logs, and cancels a remote GitHub workflow run. */ -public final class WorkflowRunProcessHandler extends ProcessHandler { +public class WorkflowRunProcessHandler extends ProcessHandler { - private final WorkflowRunRequest request; - private final WorkflowRunClient client; + private final WorkflowRun.Request request; + private final WorkflowRun client; private final Project project; private final PollSettings pollSettings; - private WorkflowRunJobConsole jobConsole = WorkflowRunJobConsole.none(); + private JobConsole jobConsole = JobConsole.none(); private final AtomicBoolean stopping = new AtomicBoolean(false); private final AtomicBoolean terminated = new AtomicBoolean(false); private final AtomicBoolean deleteRequested = new AtomicBoolean(false); @@ -47,24 +57,24 @@ public final class WorkflowRunProcessHandler extends ProcessHandler { private final AtomicLong runId = new AtomicLong(-1); private final AtomicReference> task = new AtomicReference<>(); - WorkflowRunProcessHandler(final Project project, final WorkflowRunRequest request, final WorkflowRunClient client) { + WorkflowRunProcessHandler(final Project project, final WorkflowRun.Request request, final WorkflowRun client) { this(project, request, client, PollSettings.defaults()); } WorkflowRunProcessHandler( final Project project, - final WorkflowRunRequest request, - final WorkflowRunClient client, + final WorkflowRun.Request request, + final WorkflowRun client, final Executor executor ) { this(project, request, client, PollSettings.defaults()); - this.jobConsole = new WorkflowRunConsoleTabs(project, executor, this); + this.jobConsole = new WorkflowRunView(project, executor, this); } WorkflowRunProcessHandler( final Project project, - final WorkflowRunRequest request, - final WorkflowRunClient client, + final WorkflowRun.Request request, + final WorkflowRun client, final PollSettings pollSettings ) { this.project = project; @@ -75,19 +85,19 @@ public final class WorkflowRunProcessHandler extends ProcessHandler { WorkflowRunProcessHandler( final Project project, - final WorkflowRunRequest request, - final WorkflowRunClient client, + final WorkflowRun.Request request, + final WorkflowRun client, final PollSettings pollSettings, - final WorkflowRunJobConsole jobConsole + final JobConsole jobConsole ) { this(project, request, client, pollSettings); - this.jobConsole = jobConsole == null ? WorkflowRunJobConsole.none() : jobConsole; + this.jobConsole = jobConsole == null ? JobConsole.none() : jobConsole; } @Override public void startNotify() { super.startNotify(); - WorkflowRunTracker.getInstance(project).register(request.workflowPath(), this); + WorkflowRun.Tracker.getInstance(project).register(request.workflowPath(), this); task.set(ApplicationManager.getApplication().executeOnPooledThread(this::runWorkflow)); } @@ -110,7 +120,7 @@ protected void destroyProcessImpl() { private void cancelRemoteRun(final long id) { if (id > 0) { try { - final WorkflowRunClient.CancelResult result = client.cancel(request, id); + final WorkflowRun.CancelResult result = client.cancel(request, id); stderr(GitHubWorkflowBundle.message("workflow.run.cancel.http", result.statusCode()) + "\n"); } catch (final IOException | InterruptedException exception) { if (exception instanceof InterruptedException) { @@ -126,7 +136,7 @@ private void cancelRemoteRun(final long id) { protected void detachProcessImpl() { stopping.set(true); if (terminated.compareAndSet(false, true)) { - WorkflowRunTracker.getInstance(project).unregister(request.workflowPath(), this); + WorkflowRun.Tracker.getInstance(project).unregister(request.workflowPath(), this); jobConsole.close(); notifyProcessDetached(); } @@ -144,19 +154,9 @@ public boolean detachIsDefault() { private void runWorkflow() { try { - stdout(dispatchMessage() + "\n"); - final WorkflowRunClient.DispatchResult dispatch = client.dispatch(request); - final long id = resolveRunId(dispatch); - if (id > 0) { - runId.set(id); - } - final String conclusion = poll(id); - final String terminalConclusion = stopping.get() - ? "cancelled" - : hasText(conclusion) ? conclusion : "success"; - terminate(successful(terminalConclusion) ? 0 : 1, terminalConclusion); + terminate(runFromTrigger()); } catch (final IOException | RuntimeException exception) { - if (exception instanceof WorkflowRunClient.WorkflowRunHttpException httpException && httpException.accountActionRecommended()) { + if (exception instanceof WorkflowRun.WorkflowRunHttpException httpException && httpException.accountActionRecommended()) { notifyAuthenticationHelp(); } stderr(exception.getMessage() + "\n"); @@ -170,7 +170,20 @@ private void runWorkflow() { } } - private long resolveRunId(final WorkflowRunClient.DispatchResult dispatch) throws IOException, InterruptedException { + private RunOutcome runFromTrigger() throws IOException, InterruptedException { + final long id = dispatchFromTrigger(); + if (id > 0) { + runId.set(id); + } + return RunOutcome.from(poll(id), stopping.get()); + } + + private long dispatchFromTrigger() throws IOException, InterruptedException { + stdout(dispatchMessage() + "\n"); + return resolveRunId(client.dispatch(request)); + } + + private long resolveRunId(final WorkflowRun.DispatchResult dispatch) throws IOException, InterruptedException { if (hasText(dispatch.htmlUrl())) { stdout(GitHubWorkflowBundle.message("workflow.run.link", dispatch.htmlUrl()) + "\n"); } @@ -181,7 +194,7 @@ private long resolveRunId(final WorkflowRunClient.DispatchResult dispatch) throw for (int attempt = 0; attempt < 12 && !stopping.get(); attempt++) { final var latest = client.latestRun(request); if (latest.isPresent()) { - final WorkflowRunClient.RunStatus run = latest.get(); + final WorkflowRun.RunStatus run = latest.get(); if (hasText(run.htmlUrl())) { stdout(GitHubWorkflowBundle.message("workflow.run.link", run.htmlUrl()) + "\n"); } @@ -197,10 +210,10 @@ private String poll(final long id) throws IOException, InterruptedException { if (id <= 0) { return ""; } - WorkflowRunClient.RunStatus previous = new WorkflowRunClient.RunStatus(id, "", "", ""); + WorkflowRun.RunStatus previous = new WorkflowRun.RunStatus(id, "", "", ""); final Map jobLogs = new LinkedHashMap<>(); while (!stopping.get()) { - final WorkflowRunClient.RunStatus status = client.status(request, id); + final WorkflowRun.RunStatus status = client.status(request, id); if (!status.status().equals(previous.status()) || !status.conclusion().equals(previous.conclusion())) { stdout(GitHubWorkflowBundle.message("workflow.run.status", status.status(), suffix(status.conclusion())) + "\n"); previous = status; @@ -218,18 +231,18 @@ private String poll(final long id) throws IOException, InterruptedException { private void streamJobLogs(final long id, final Map jobLogs, final boolean finalPass) throws IOException, InterruptedException { final long now = System.currentTimeMillis(); boolean changed = false; - for (final WorkflowRunClient.JobStatus job : client.jobs(request, id)) { + for (final WorkflowRun.JobStatus job : client.jobs(request, id)) { final JobLogState state = jobLogs.computeIfAbsent(job.id(), ignored -> new JobLogState()); - if (!job.status().equals(state.status) || !job.conclusion().equals(state.conclusion)) { + if (state.changed(job)) { printJobHeader(job, state); - updateTiming(state, job, now); + state.seen(job, now); final String status = GitHubWorkflowBundle.message( "workflow.run.job.main", statePrefix(job), job.name(), job.status(), suffix(job.conclusion()), - durationSuffix(state, now) + state.durationSuffix(now) ) + "\n"; stdout(status); jobConsole.jobStatus(job, GitHubWorkflowBundle.message( @@ -237,11 +250,9 @@ private void streamJobLogs(final long id, final Map jobLogs, statePrefix(job), job.status(), suffix(job.conclusion()), - durationSuffix(state, now) + state.durationSuffix(now) ) + "\n"); - state.status = job.status(); - state.conclusion = job.conclusion(); - state.name = job.name(); + state.status(job); changed = true; } if (shouldFetchLog(job, state, now, finalPass)) { @@ -249,174 +260,76 @@ private void streamJobLogs(final long id, final Map jobLogs, } } if (changed) { - stdout(overview(jobLogs, now)); + stdout(overview(jobLogs.values(), now)); } } private boolean shouldFetchLog( - final WorkflowRunClient.JobStatus job, + final WorkflowRun.JobStatus job, final JobLogState state, final long now, final boolean finalPass ) { - if (!"in_progress".equals(job.status()) && !"completed".equals(job.status())) { - return false; - } - if (finalPass || "completed".equals(job.status())) { - return !state.finalLogFetched; - } - if (now < state.nextLiveLogFetchMillis) { - return false; - } - return now - state.lastLogFetchMillis >= pollSettings.logPollMillis(); + return state.shouldFetchLog(job, now, finalPass, pollSettings.logPollMillis()); } private void fetchJobLog( - final WorkflowRunClient.JobStatus job, + final WorkflowRun.JobStatus job, final JobLogState state, final long now, final boolean finalPass ) throws InterruptedException { - state.lastLogFetchMillis = now; + state.lastLogFetchMillis(now); try { final String logs = client.jobLogs(request, job.id()); if (hasText(logs)) { printLogDelta(job, state, logs); } if (finalPass || "completed".equals(job.status())) { - state.finalLogFetched = true; + state.finalLogFetched(true); } } catch (final IOException exception) { if (shouldDeferLiveLogFailure(exception, finalPass)) { - if (!state.liveLogNoticeShown) { + if (!state.liveLogNoticeShown()) { final String notice = GitHubWorkflowBundle.message("workflow.run.logs.later") + "\n"; if (!jobConsole.jobStatus(job, notice)) { stdout(GitHubWorkflowBundle.message("workflow.run.job.logs.later", job.name(), notice)); } - state.liveLogNoticeShown = true; + state.liveLogNoticeShown(true); } - state.nextLiveLogFetchMillis = now + pollSettings.liveLogFailureRetryMillis(); + state.nextLiveLogFetchMillis(now + pollSettings.liveLogFailureRetryMillis()); return; } - if (finalPass || !state.logErrorShown) { + if (finalPass || !state.logErrorShown()) { final String message = GitHubWorkflowBundle.message("workflow.run.log.failed", exception.getMessage()) + "\n"; if (!jobConsole.jobStderr(job, message)) { stderr(GitHubWorkflowBundle.message("workflow.run.log.failed.job", job.name(), exception.getMessage()) + "\n"); } - state.logErrorShown = true; + state.logErrorShown(true); } } } - private void printLogDelta(final WorkflowRunClient.JobStatus job, final JobLogState state, final String logs) { - final String text = logs.stripTrailing(); - if (text.length() <= state.printedLength) { - return; - } - final String delta = text.substring(state.printedLength).stripLeading(); + private void printLogDelta(final WorkflowRun.JobStatus job, final JobLogState state, final String logs) { + final String delta = state.delta(logs); if (hasText(delta)) { if (!jobConsole.jobLog(job, delta + "\n")) { - final String rendered = state.fallbackRenderer.renderPlain(delta + "\n"); + final String rendered = state.plain(delta + "\n"); final String fallbackText = "\n== " + job.name() + " ==\n" + rendered; stdout(fallbackText); } } - state.printedLength = text.length(); } - private void printJobHeader(final WorkflowRunClient.JobStatus job, final JobLogState state) { - if (state.headerPrinted) { + private void printJobHeader(final WorkflowRun.JobStatus job, final JobLogState state) { + if (state.headerPrinted()) { return; } final String url = hasText(job.htmlUrl()) ? GitHubWorkflowBundle.message("workflow.run.job.url", job.htmlUrl()) + "\n" : ""; final String header = GitHubWorkflowBundle.message("workflow.run.job.header", job.name()) + "\n" + url; stdout(header); jobConsole.jobStatus(job, header); - state.headerPrinted = true; - } - - private static void updateTiming(final JobLogState state, final WorkflowRunClient.JobStatus job, final long now) { - if (state.firstSeenMillis == 0) { - state.firstSeenMillis = now; - } - if ("in_progress".equals(job.status()) && state.startedMillis == 0) { - state.startedMillis = now; - } - if ("completed".equals(job.status()) && state.completedMillis == 0) { - state.completedMillis = now; - } - } - - private static String overview(final Map states, final long now) { - final long total = states.size(); - final long done = states.values().stream().filter(state -> "completed".equals(state.status)).count(); - final long running = states.values().stream().filter(state -> "in_progress".equals(state.status)).count(); - final StringBuilder result = new StringBuilder() - .append(GitHubWorkflowBundle.message("workflow.run.overview", progressBar(done, total), done, total, running)) - .append("\n"); - int index = 0; - for (final JobLogState state : states.values()) { - final boolean last = ++index == states.size(); - result.append(last ? "`-- " : "|-- ") - .append(statePrefix(state)) - .append(" ") - .append(state.name) - .append(durationSuffix(state, now)) - .append("\n"); - } - return result.toString(); - } - - private static String progressBar(final long done, final long total) { - if (total <= 0) { - return "[----------]"; - } - final int width = 10; - final int filled = (int) Math.min(width, Math.max(0, done * width / total)); - return "[" + "#".repeat(filled) + "-".repeat(width - filled) + "]"; - } - - private static String durationSuffix(final JobLogState state, final long now) { - final long start = state.startedMillis > 0 ? state.startedMillis : state.firstSeenMillis; - final long end = state.completedMillis > 0 ? state.completedMillis : now; - if (start <= 0 || end < start) { - return ""; - } - return " " + formatDuration(end - start); - } - - private static String formatDuration(final long millis) { - final long seconds = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(millis)); - final long minutes = seconds / 60; - return String.format(Locale.ROOT, "%02d:%02d", minutes, seconds % 60); - } - - private static String statePrefix(final WorkflowRunClient.JobStatus job) { - if ("completed".equals(job.status())) { - return successful(job.conclusion()) - ? GitHubWorkflowBundle.message("workflow.run.state.ok") - : GitHubWorkflowBundle.message("workflow.run.state.fail"); - } - if ("in_progress".equals(job.status())) { - return GitHubWorkflowBundle.message("workflow.run.state.running"); - } - return GitHubWorkflowBundle.message("workflow.run.state.waiting"); - } - - private static String statePrefix(final JobLogState state) { - if ("completed".equals(state.status)) { - return successful(state.conclusion) - ? GitHubWorkflowBundle.message("workflow.run.state.ok") - : GitHubWorkflowBundle.message("workflow.run.state.fail"); - } - if ("in_progress".equals(state.status)) { - return GitHubWorkflowBundle.message("workflow.run.state.running"); - } - return GitHubWorkflowBundle.message("workflow.run.state.waiting"); - } - - private static boolean successful(final String conclusion) { - return "success".equals(conclusion) || "skipped".equals(conclusion) || "neutral".equals(conclusion); + state.headerPrinted(true); } private String dispatchMessage() { @@ -435,11 +348,7 @@ private String workflowUrl() { } private static boolean shouldDeferLiveLogFailure(final IOException exception, final boolean finalPass) { - return !finalPass && exception instanceof WorkflowRunClient.WorkflowRunHttpException; - } - - private static String suffix(final String conclusion) { - return conclusion == null || conclusion.isBlank() ? "" : "/" + conclusion; + return !finalPass && exception instanceof WorkflowRun.WorkflowRunHttpException; } private static boolean hasText(final String value) { @@ -456,24 +365,18 @@ void deleteRemoteRun() { return; } workflowStatus(GitHubWorkflowBundle.message("workflow.run.delete.requested", id) + "\n", false); - ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - final WorkflowRunClient.DeleteResult result = client.delete(request, id); - final String message = result.accepted() - ? GitHubWorkflowBundle.message("workflow.run.delete.done", id) - : GitHubWorkflowBundle.message("workflow.run.delete.http", result.statusCode()); - workflowStatus(message + "\n", !result.accepted()); - if (result.accepted()) { - jobConsole.runDeleted(id); - } else { - jobConsole.runDeleteFailed(id); - deleteRequested.set(false); - } - } catch (final IOException | InterruptedException exception) { - if (exception instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - workflowStatus(GitHubWorkflowBundle.message("workflow.run.delete.failed", exception.getMessage()) + "\n", true); + inBackground("workflow.run.delete.failed", exception -> { + jobConsole.runDeleteFailed(id); + deleteRequested.set(false); + }, () -> { + final WorkflowRun.DeleteResult result = client.delete(request, id); + final String message = result.accepted() + ? GitHubWorkflowBundle.message("workflow.run.delete.done", id) + : GitHubWorkflowBundle.message("workflow.run.delete.http", result.statusCode()); + workflowStatus(message + "\n", !result.accepted()); + if (result.accepted()) { + jobConsole.runDeleted(id); + } else { jobConsole.runDeleteFailed(id); deleteRequested.set(false); } @@ -493,23 +396,15 @@ void rerunRemoteRun(final boolean failedOnly) { workflowStatus(GitHubWorkflowBundle.message(failedOnly ? "workflow.run.rerun.failed.requested" : "workflow.run.rerun.all.requested", id) + "\n", false); - ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - final WorkflowRunClient.RerunResult result = client.rerun(request, id, failedOnly); - final String message = result.accepted() - ? GitHubWorkflowBundle.message(failedOnly - ? "workflow.run.rerun.failed.done" - : "workflow.run.rerun.all.done", id) - : GitHubWorkflowBundle.message("workflow.run.rerun.http", result.statusCode()); - workflowStatus(message + "\n", !result.accepted()); - } catch (final IOException | InterruptedException exception) { - if (exception instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - workflowStatus(GitHubWorkflowBundle.message("workflow.run.rerun.failed", exception.getMessage()) + "\n", true); - } finally { - gate.set(false); - } + inBackground("workflow.run.rerun.failed", ignored -> gate.set(false), () -> { + final WorkflowRun.RerunResult result = client.rerun(request, id, failedOnly); + final String message = result.accepted() + ? GitHubWorkflowBundle.message(failedOnly + ? "workflow.run.rerun.failed.done" + : "workflow.run.rerun.all.done", id) + : GitHubWorkflowBundle.message("workflow.run.rerun.http", result.statusCode()); + workflowStatus(message + "\n", !result.accepted()); + gate.set(false); }); } @@ -550,18 +445,11 @@ void downloadJobLog(final long jobId, final String jobName) { return; } workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.log.requested", jobName) + "\n", false); - ApplicationManager.getApplication().executeOnPooledThread(() -> { - try { - final String log = client.jobLogs(request, jobId); - final Path file = WorkflowRunDownloads.writeJobLog(request, id, jobId, jobName, log); - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.log.done", file) + "\n", false); - WorkflowRunDownloads.reveal(file); - } catch (final IOException | InterruptedException exception) { - if (exception instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.failed", exception.getMessage()) + "\n", true); - } + inBackground("workflow.run.download.failed", () -> { + final String log = client.jobLogs(request, jobId); + final Path file = writeJobLog(id, jobId, jobName, log); + workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.log.done", file) + "\n", false); + reveal(file); }); } @@ -572,40 +460,52 @@ void downloadArtifacts() { return; } workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifacts.requested") + "\n", false); + inBackground("workflow.run.download.failed", () -> { + final List artifacts = client.artifacts(request, id); + if (artifacts.isEmpty()) { + artifactAvailability.set(0); + workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifacts.empty") + "\n", false); + return; + } + Path lastFile = null; + int downloaded = 0; + for (final WorkflowRun.ArtifactStatus artifact : artifacts) { + if (artifact.expired()) { + workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifact.expired", artifact.name()) + "\n", false); + continue; + } + final byte[] zip = client.artifactZip(request, artifact.id()); + lastFile = writeArtifact(id, artifact, zip); + downloaded++; + workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifact.done", artifact.name(), lastFile) + "\n", false); + } + if (downloaded == 0) { + artifactAvailability.set(0); + workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifacts.empty") + "\n", false); + return; + } + artifactAvailability.set(1); + if (lastFile != null) { + reveal(lastFile.getParent()); + } + }); + } + + private void inBackground(final String failureKey, final RemoteWork work) { + inBackground(failureKey, ignored -> { + }, work); + } + + private void inBackground(final String failureKey, final Consumer onFailure, final RemoteWork work) { ApplicationManager.getApplication().executeOnPooledThread(() -> { try { - final List artifacts = client.artifacts(request, id); - if (artifacts.isEmpty()) { - artifactAvailability.set(0); - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifacts.empty") + "\n", false); - return; - } - Path lastFile = null; - int downloaded = 0; - for (final WorkflowRunClient.ArtifactStatus artifact : artifacts) { - if (artifact.expired()) { - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifact.expired", artifact.name()) + "\n", false); - continue; - } - final byte[] zip = client.artifactZip(request, artifact.id()); - lastFile = WorkflowRunDownloads.writeArtifact(request, id, artifact, zip); - downloaded++; - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifact.done", artifact.name(), lastFile) + "\n", false); - } - if (downloaded == 0) { - artifactAvailability.set(0); - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.artifacts.empty") + "\n", false); - return; - } - artifactAvailability.set(1); - if (lastFile != null) { - WorkflowRunDownloads.reveal(lastFile.getParent()); - } + work.run(); } catch (final IOException | InterruptedException exception) { if (exception instanceof InterruptedException) { Thread.currentThread().interrupt(); } - workflowStatus(GitHubWorkflowBundle.message("workflow.run.download.failed", exception.getMessage()) + "\n", true); + workflowStatus(GitHubWorkflowBundle.message(failureKey, exception.getMessage()) + "\n", true); + onFailure.accept(exception); } }); } @@ -619,18 +519,22 @@ private void workflowStatus(final String text, final boolean error) { private void terminate(final int exitCode, final String conclusion) { if (terminated.compareAndSet(false, true)) { - WorkflowRunTracker.getInstance(project).unregister(request.workflowPath(), this); + WorkflowRun.Tracker.getInstance(project).unregister(request.workflowPath(), this); jobConsole.runFinished(runId.get(), conclusion); jobConsole.close(); notifyProcessTerminated(exitCode); } } + private void terminate(final RunOutcome outcome) { + terminate(outcome.exitCode(), outcome.conclusion()); + } + private void notifyAuthenticationHelp() { final var notification = NotificationGroupManager.getInstance() .getNotificationGroup("GitHub Workflow") .createNotification( - GitHubWorkflowBundle.message("workflow.run.notification.auth", GitHubRequestAuthorizations.settingsHint()), + GitHubWorkflowBundle.message("workflow.run.notification.auth", RemoteActionProviders.Authorizations.settingsHint()), NotificationType.WARNING ); notification.addAction(NotificationAction.createSimple(GitHubWorkflowBundle.message("workflow.run.notification.openSettings"), () -> @@ -647,18 +551,119 @@ private void stderr(final String text) { notifyTextAvailable(text, ProcessOutputTypes.STDERR); } - record PollSettings(long statusPollMillis, long logPollMillis, long runDiscoveryMillis, long liveLogFailureRetryMillis) { + private Path writeJobLog( + final long id, + final long jobId, + final String jobName, + final String log + ) throws IOException { + final Path file = runDirectory(id).resolve(safeName(jobName) + "-" + jobId + ".log"); + Files.writeString(file, Optional.ofNullable(log).orElse(""), StandardCharsets.UTF_8); + return file; + } + + private Path writeArtifact( + final long id, + final WorkflowRun.ArtifactStatus artifact, + final byte[] zip + ) throws IOException { + final Path file = runDirectory(id).resolve(safeName(artifact.name()) + "-" + artifact.id() + ".zip"); + Files.write(file, Optional.ofNullable(zip).orElseGet(() -> new byte[0])); + return file; + } + + private Path runDirectory(final long id) throws IOException { + final Path directory = Path.of( + PathManager.getSystemPath(), + "github-workflow-plugin", + "downloads", + safeName(request.repositorySlug()), + "run-" + id + ); + Files.createDirectories(directory); + return directory; + } + + private static void reveal(final Path path) { + if (path == null) { + return; + } + ApplicationManager.getApplication().invokeLater(() -> RevealFileAction.openFile(path.toFile())); + } - PollSettings(final long statusPollMillis, final long logPollMillis, final long runDiscoveryMillis) { - this(statusPollMillis, logPollMillis, runDiscoveryMillis, Math.max(logPollMillis, 60_000)); + private static String safeName(final String value) { + final String normalized = Optional.ofNullable(value) + .filter(text -> !text.isBlank()) + .orElse("download") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9._-]+", "-") + .replaceAll("^-+|-+$", ""); + return normalized.isBlank() ? "download" : normalized; + } + + private static String overview(final Collection states, final long now) { + final long total = states.size(); + final long done = states.stream().filter(JobLogState::completed).count(); + final long running = states.stream().filter(JobLogState::running).count(); + final StringBuilder result = new StringBuilder() + .append(GitHubWorkflowBundle.message("workflow.run.overview", progressBar(done, total), done, total, running)) + .append("\n"); + int index = 0; + for (final JobLogState state : states) { + final boolean last = ++index == states.size(); + result.append(last ? "`-- " : "|-- ") + .append(statePrefix(state)) + .append(" ") + .append(state.name()) + .append(state.durationSuffix(now)) + .append("\n"); } + return result.toString(); + } - private static PollSettings defaults() { - return new PollSettings(10_000, 30_000, 2_000, 60_000); + private static String progressBar(final long done, final long total) { + if (total <= 0) { + return "[----------]"; } + final int width = 10; + final int filled = (int) Math.min(width, Math.max(0, done * width / total)); + return "[" + "#".repeat(filled) + "-".repeat(width - filled) + "]"; + } + + private static String statePrefix(final WorkflowRun.JobStatus job) { + return statePrefix(job.status(), job.conclusion()); + } + + private static String statePrefix(final JobLogState state) { + return statePrefix(state.status(), state.conclusion()); + } + + private static String statePrefix(final String status, final String conclusion) { + if ("completed".equals(status)) { + return successful(conclusion) + ? GitHubWorkflowBundle.message("workflow.run.state.ok") + : GitHubWorkflowBundle.message("workflow.run.state.fail"); + } + if ("in_progress".equals(status)) { + return GitHubWorkflowBundle.message("workflow.run.state.running"); + } + return GitHubWorkflowBundle.message("workflow.run.state.waiting"); + } + + private static String suffix(final String conclusion) { + return conclusion == null || conclusion.isBlank() ? "" : "/" + conclusion; + } + + private static boolean successful(final String conclusion) { + return "success".equals(conclusion) || "skipped".equals(conclusion) || "neutral".equals(conclusion); + } + + private static String formatDuration(final long millis) { + final long seconds = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(millis)); + return String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60); } - private static final class JobLogState { + private static class JobLogState { private String name = "job"; private String status = ""; private String conclusion = ""; @@ -668,10 +673,212 @@ private static final class JobLogState { private int printedLength = 0; private long lastLogFetchMillis = 0; private long nextLiveLogFetchMillis = 0; - private final WorkflowRunLogRenderer fallbackRenderer = new WorkflowRunLogRenderer(); + private final WorkflowRunView.LogRenderer fallbackRenderer = new WorkflowRunView.LogRenderer(); private boolean finalLogFetched = false; private boolean logErrorShown = false; private boolean headerPrinted = false; private boolean liveLogNoticeShown = false; + + private boolean changed(final WorkflowRun.JobStatus job) { + return !job.status().equals(status) || !job.conclusion().equals(conclusion); + } + + private JobLogState seen(final WorkflowRun.JobStatus job, final long now) { + if (firstSeenMillis == 0) { + firstSeenMillis = now; + } + if ("in_progress".equals(job.status()) && startedMillis == 0) { + startedMillis = now; + } + if ("completed".equals(job.status()) && completedMillis == 0) { + completedMillis = now; + } + return this; + } + + private JobLogState status(final WorkflowRun.JobStatus job) { + status = job.status(); + conclusion = job.conclusion(); + name = job.name(); + return this; + } + + private boolean shouldFetchLog( + final WorkflowRun.JobStatus job, + final long now, + final boolean finalPass, + final long logPollMillis + ) { + if (!"in_progress".equals(job.status()) && !"completed".equals(job.status())) { + return false; + } + if (finalPass || "completed".equals(job.status())) { + return !finalLogFetched; + } + if (now < nextLiveLogFetchMillis) { + return false; + } + return now - lastLogFetchMillis >= logPollMillis; + } + + private String delta(final String logs) { + final String text = logs.stripTrailing(); + if (text.length() <= printedLength) { + return ""; + } + final String result = text.substring(printedLength).stripLeading(); + printedLength = text.length(); + return result; + } + + private String plain(final String text) { + return fallbackRenderer.renderPlain(text); + } + + private String durationSuffix(final long now) { + final long start = startedMillis > 0 ? startedMillis : firstSeenMillis; + final long end = completedMillis > 0 ? completedMillis : now; + if (start <= 0 || end < start) { + return ""; + } + return " " + formatDuration(end - start); + } + + private boolean completed() { + return "completed".equals(status); + } + + private boolean running() { + return "in_progress".equals(status); + } + + private String name() { + return name; + } + + private String status() { + return status; + } + + private String conclusion() { + return conclusion; + } + + private JobLogState lastLogFetchMillis(final long value) { + lastLogFetchMillis = value; + return this; + } + + private JobLogState finalLogFetched(final boolean value) { + finalLogFetched = value; + return this; + } + + private boolean logErrorShown() { + return logErrorShown; + } + + private JobLogState logErrorShown(final boolean value) { + logErrorShown = value; + return this; + } + + private boolean headerPrinted() { + return headerPrinted; + } + + private JobLogState headerPrinted(final boolean value) { + headerPrinted = value; + return this; + } + + private boolean liveLogNoticeShown() { + return liveLogNoticeShown; + } + + private JobLogState liveLogNoticeShown(final boolean value) { + liveLogNoticeShown = value; + return this; + } + + private JobLogState nextLiveLogFetchMillis(final long value) { + nextLiveLogFetchMillis = value; + return this; + } + } + + record PollSettings(long statusPollMillis, long logPollMillis, long runDiscoveryMillis, long liveLogFailureRetryMillis) { + + PollSettings(final long statusPollMillis, final long logPollMillis, final long runDiscoveryMillis) { + this(statusPollMillis, logPollMillis, runDiscoveryMillis, Math.max(logPollMillis, 60_000)); + } + + private static PollSettings defaults() { + return new PollSettings(10_000, 30_000, 2_000, 60_000); + } + } + + /** + * Receives workflow job status and logs for a Run tool-window workflow view. + */ + interface JobConsole { + + boolean jobStatus(WorkflowRun.JobStatus job, String text); + + boolean jobStdout(WorkflowRun.JobStatus job, String text); + + boolean jobStderr(WorkflowRun.JobStatus job, String text); + + default boolean jobLog(final WorkflowRun.JobStatus job, final String text) { + return jobStdout(job, text); + } + + default void workflowStatus(final String text, final boolean error) { + } + + default void runFinished(final long runId, final String conclusion) { + } + + default void runDeleted(final long runId) { + } + + default void runDeleteFailed(final long runId) { + } + + default void close() { + } + + static JobConsole none() { + return new JobConsole() { + @Override + public boolean jobStatus(final WorkflowRun.JobStatus job, final String text) { + return false; + } + + @Override + public boolean jobStdout(final WorkflowRun.JobStatus job, final String text) { + return false; + } + + @Override + public boolean jobStderr(final WorkflowRun.JobStatus job, final String text) { + return false; + } + }; + } + } + + private record RunOutcome(int exitCode, String conclusion) { + + private static RunOutcome from(final String conclusion, final boolean stopping) { + final String terminalConclusion = stopping + ? "cancelled" + : hasText(conclusion) ? conclusion : "success"; + return new RunOutcome(successful(terminalConclusion) ? 0 : 1, terminalConclusion); + } + } + + private interface RemoteWork { + void run() throws IOException, InterruptedException; } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabs.java b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunView.java similarity index 66% rename from src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabs.java rename to src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunView.java index 39494eb..2df96dc 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/run/WorkflowRunView.java @@ -1,4 +1,6 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.execution.Executor; import com.intellij.execution.filters.TextConsoleBuilderFactory; @@ -54,13 +56,16 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BooleanSupplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.swing.UIManager; /** * Adds a JUnit-style workflow tree to the Run tool window and routes selected-node output to one detail console. */ -final class WorkflowRunConsoleTabs implements WorkflowRunJobConsole { +class WorkflowRunView implements WorkflowRunProcessHandler.JobConsole { private static final int MAX_ATTACH_ATTEMPTS = 20; private static final String CONTENT_ID = "github.workflow.jobs"; @@ -96,7 +101,7 @@ public void onTextAvailable(final @NotNull ProcessEvent event, final @NotNull Ke private volatile long terminalRunId = -1; private volatile String terminalConclusion = ""; - WorkflowRunConsoleTabs(final Project project, final @Nullable Executor executor, final WorkflowRunProcessHandler processHandler) { + WorkflowRunView(final Project project, final @Nullable Executor executor, final WorkflowRunProcessHandler processHandler) { this.project = project; this.executor = executor; this.processHandler = processHandler; @@ -104,17 +109,17 @@ public void onTextAvailable(final @NotNull ProcessEvent event, final @NotNull Ke } @Override - public boolean jobStatus(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStatus(final WorkflowRun.JobStatus job, final String text) { return print(job, text, ConsoleViewContentType.SYSTEM_OUTPUT); } @Override - public boolean jobStdout(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStdout(final WorkflowRun.JobStatus job, final String text) { return print(job, text, ConsoleViewContentType.NORMAL_OUTPUT); } @Override - public boolean jobLog(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobLog(final WorkflowRun.JobStatus job, final String text) { if (executor == null || job.id() < 0) { return false; } @@ -126,7 +131,7 @@ public boolean jobLog(final WorkflowRunClient.JobStatus job, final String text) } @Override - public boolean jobStderr(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStderr(final WorkflowRun.JobStatus job, final String text) { return print(job, text, ConsoleViewContentType.ERROR_OUTPUT); } @@ -168,7 +173,7 @@ public void close() { } } - private boolean print(final WorkflowRunClient.JobStatus job, final String text, final ConsoleViewContentType contentType) { + private boolean print(final WorkflowRun.JobStatus job, final String text, final ConsoleViewContentType contentType) { if (executor == null || job.id() < 0) { return false; } @@ -185,7 +190,7 @@ private Optional descriptor() { return Optional.ofNullable(RunContentManager.getInstance(project).findContentDescriptor(executor, processHandler)); } - private JobNode jobNode(final WorkflowRunClient.JobStatus job) { + private JobNode jobNode(final WorkflowRun.JobStatus job) { return jobs.computeIfAbsent(job.id(), ignored -> { final JobNode node = new JobNode(job); ApplicationManager.getApplication().invokeLater(() -> addJobNode(node)); @@ -546,7 +551,7 @@ private static ConsoleViewContentType processContentType(final Key outputType) { : ConsoleViewContentType.SYSTEM_OUTPUT; } - private static ConsoleViewContentType contentType(final WorkflowRunLogRenderer.Kind kind) { + private static ConsoleViewContentType contentType(final LogRenderer.Kind kind) { return switch (kind) { case SYSTEM -> ConsoleViewContentType.SYSTEM_OUTPUT; case WARNING -> ConsoleViewContentType.LOG_WARNING_OUTPUT; @@ -555,6 +560,43 @@ private static ConsoleViewContentType contentType(final WorkflowRunLogRenderer.K }; } + private Icon aggregateIcon(final List nodes, final boolean running, final Icon emptyIcon, final boolean cancelledRun) { + if (running) { + return AnimatedIcon.Default.INSTANCE; + } + if (cancelledRun || nodes.stream().anyMatch(JobNode::cancelled)) { + return AllIcons.RunConfigurations.TestTerminated; + } + if (nodes.stream().anyMatch(JobNode::failed)) { + return AllIcons.General.Error; + } + if (nodes.stream().anyMatch(JobNode::skipped)) { + return AllIcons.RunConfigurations.TestState.Yellow2; + } + return nodes.isEmpty() ? emptyIcon : AllIcons.General.GreenCheckmark; + } + + private boolean terminal() { + return !terminalConclusion.isBlank(); + } + + static JobDisplayName splitJobName(final String name) { + final String normalized = name == null || name.isBlank() + ? GitHubWorkflowBundle.message("workflow.run.job.fallbackName", -1) + : name; + final int separator = normalized.indexOf(" / "); + if (separator <= 0 || separator + 3 >= normalized.length()) { + return new JobDisplayName("", normalized); + } + return new JobDisplayName(normalized.substring(0, separator), normalized.substring(separator + 3)); + } + + private static String displayBaseName(final WorkflowRun.JobStatus job) { + return job.name() == null || job.name().isBlank() + ? GitHubWorkflowBundle.message("workflow.run.job.fallbackName", job.id()) + : job.name(); + } + private static boolean successful(final String conclusion) { return "success".equals(conclusion) || "skipped".equals(conclusion) || "neutral".equals(conclusion); } @@ -569,22 +611,345 @@ private static String normalizeConclusion(final String conclusion) { : conclusion.toLowerCase(Locale.ROOT); } - private boolean terminal() { - return !terminalConclusion.isBlank(); + private static String formatDuration(final long millis) { + final long seconds = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(millis)); + return String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60); } - static JobDisplayName splitJobName(final String name) { - final String normalized = name == null || name.isBlank() - ? GitHubWorkflowBundle.message("workflow.run.job.fallbackName", -1) - : name; - final int separator = normalized.indexOf(" / "); - if (separator <= 0 || separator + 3 >= normalized.length()) { - return new JobDisplayName("", normalized); + static class LogRenderer { + + private static final Pattern TIMESTAMP = Pattern.compile("^\\x{FEFF}?\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z\\s+"); + private static final Pattern GITHUB_COMMAND = Pattern.compile("^##\\[([^]]+)](.*)$"); + private static final Pattern WORKFLOW_COMMAND = Pattern.compile("^::([^: ]+)(?: [^:]*)?::(.*)$"); + private static final Pattern ANSI_SGR = Pattern.compile("\\x1B\\[([0-9;]*)m"); + private static final Pattern ANSI_CONTROL = Pattern.compile("\\x1B\\[[0-?]*[ -/]*[@-~]"); + + private int lineNumber = 0; + private boolean printedAny = false; + + static List renderOnce(final String text) { + return new LogRenderer().render(text); + } + + static String renderPlainOnce(final String text) { + return new LogRenderer().renderPlain(text); + } + + List render(final String text) { + if (text == null || text.isEmpty()) { + return List.of(); + } + final List result = new ArrayList<>(); + int start = 0; + while (start < text.length()) { + final int next = nextLineEnd(text, start); + appendLine(result, text.substring(start, next)); + start = next; + } + return List.copyOf(result); + } + + String renderPlain(final String text) { + final StringBuilder result = new StringBuilder(); + for (final Segment segment : render(text)) { + result.append(segment.text()); + } + return result.toString(); + } + + private void appendLine(final List result, final String rawLine) { + final LineParts parts = splitLine(rawLine); + final AnsiLine ansiLine = stripAnsi(TIMESTAMP.matcher(parts.text()).replaceFirst("")); + final String line = ansiLine.text(); + final Matcher githubCommand = GITHUB_COMMAND.matcher(line); + if (githubCommand.matches()) { + appendGitHubCommand(result, githubCommand.group(1), githubCommand.group(2), parts.separator()); + return; + } + final Matcher workflowCommand = WORKFLOW_COMMAND.matcher(line); + if (workflowCommand.matches()) { + appendWorkflowCommand(result, workflowCommand.group(1), workflowCommand.group(2), parts.separator()); + return; + } + appendNumbered(result, line, ansiLine.kind() == Kind.NORMAL ? inferredKind(line) : ansiLine.kind(), parts.separator()); + } + + private void appendGitHubCommand(final List result, final String command, final String value, final String separator) { + final String name = commandName(command); + switch (name) { + case "group" -> appendBlockHeader(result, value); + case "endgroup", "/group" -> appendBlockEnd(); + case "command" -> appendNumbered(result, GitHubWorkflowBundle.message("workflow.log.command") + " " + value, Kind.SYSTEM, separator); + case "warning" -> appendNumbered(result, GitHubWorkflowBundle.message("workflow.log.warning") + " " + value, Kind.WARNING, separator); + case "error" -> appendNumbered(result, GitHubWorkflowBundle.message("workflow.log.error") + " " + value, Kind.ERROR, separator); + default -> appendNumbered(result, value.isBlank() ? "[" + name + "]" : value, Kind.SYSTEM, separator); + } + } + + private void appendWorkflowCommand(final List result, final String command, final String value, final String separator) { + final String name = commandName(command); + switch (name) { + case "warning" -> appendNumbered(result, GitHubWorkflowBundle.message("workflow.log.warning") + " " + value, Kind.WARNING, separator); + case "error" -> appendNumbered(result, GitHubWorkflowBundle.message("workflow.log.error") + " " + value, Kind.ERROR, separator); + case "group" -> appendBlockHeader(result, value); + case "endgroup", "/group" -> appendBlockEnd(); + default -> appendNumbered(result, value, Kind.SYSTEM, separator); + } + } + + private void appendBlockHeader(final List result, final String title) { + final String prefix = printedAny ? "\n" : ""; + result.add(new Segment(prefix + "== " + title.strip() + " ==\n", Kind.SYSTEM)); + lineNumber = 0; + printedAny = true; + } + + private void appendBlockEnd() { + lineNumber = 0; + } + + private void appendNumbered(final List result, final String line, final Kind kind, final String separator) { + printedAny = true; + if (line.isBlank()) { + result.add(new Segment(separator, kind)); + return; + } + lineNumber++; + result.add(new Segment(String.format(Locale.ROOT, "%04d | %s%s", lineNumber, line, separator), kind)); + } + + private static AnsiLine stripAnsi(final String line) { + Kind kind = Kind.NORMAL; + final Matcher matcher = ANSI_SGR.matcher(line); + while (matcher.find()) { + kind = strongest(kind, kindForAnsi(matcher.group(1))); + } + return new AnsiLine(ANSI_CONTROL.matcher(line).replaceAll(""), kind); + } + + private static Kind kindForAnsi(final String value) { + final String[] codes = value.isBlank() ? new String[]{"0"} : value.split(";"); + Kind result = Kind.NORMAL; + for (final String code : codes) { + result = strongest(result, switch (code) { + case "31", "91" -> Kind.ERROR; + case "33", "93" -> Kind.WARNING; + case "34", "35", "36", "90", "94", "95", "96" -> Kind.SYSTEM; + default -> Kind.NORMAL; + }); + } + return result; + } + + private static Kind strongest(final Kind left, final Kind right) { + return weight(right) > weight(left) ? right : left; + } + + private static int weight(final Kind kind) { + return switch (kind) { + case ERROR -> 3; + case WARNING -> 2; + case SYSTEM -> 1; + case NORMAL -> 0; + }; + } + + private static Kind inferredKind(final String line) { + final String normalized = line.stripLeading().toLowerCase(Locale.ROOT); + if (normalized.startsWith("error:") || normalized.startsWith("fatal:")) { + return Kind.ERROR; + } + if (normalized.startsWith("warning:") || normalized.startsWith("npm warn ")) { + return Kind.WARNING; + } + return Kind.NORMAL; + } + + private static String commandName(final String command) { + final int space = command.indexOf(' '); + return (space >= 0 ? command.substring(0, space) : command).toLowerCase(Locale.ROOT); + } + + private static LineParts splitLine(final String line) { + if (line.endsWith("\r\n")) { + return new LineParts(line.substring(0, line.length() - 2), "\r\n"); + } + if (line.endsWith("\n") || line.endsWith("\r")) { + return new LineParts(line.substring(0, line.length() - 1), line.substring(line.length() - 1)); + } + return new LineParts(line, ""); + } + + private static int nextLineEnd(final String text, final int start) { + int index = start; + while (index < text.length() && text.charAt(index) != '\n' && text.charAt(index) != '\r') { + index++; + } + if (index >= text.length()) { + return index; + } + if (text.charAt(index) == '\r' && index + 1 < text.length() && text.charAt(index + 1) == '\n') { + return index + 2; + } + return index + 1; + } + + enum Kind { + NORMAL, + SYSTEM, + WARNING, + ERROR + } + + record Segment(String text, Kind kind) { + } + + private record AnsiLine(String text, Kind kind) { + } + + private record LineParts(String text, String separator) { } - return new JobDisplayName(normalized.substring(0, separator), normalized.substring(separator + 3)); } - private static final class ToolbarAction extends DumbAwareAction { + record JobDisplayName(String group, String name) { + } + + private record JobState( + long jobId, + String groupName, + String displayName, + String status, + String conclusion, + long firstSeenMillis, + long startedMillis, + long completedMillis, + int warnings, + int errors + ) { + + private static JobState from(final WorkflowRun.JobStatus job, final long now) { + final JobDisplayName name = displayName(job); + return new JobState( + job.id(), + name.group(), + name.name(), + job.status(), + normalizeConclusion(job.conclusion()), + now, + 0, + 0, + 0, + 0 + ).withTiming(job, now); + } + + private JobState update(final WorkflowRun.JobStatus job, final long now) { + final JobDisplayName name = displayName(job); + return new JobState( + jobId, + name.group(), + name.name(), + job.status(), + normalizeConclusion(job.conclusion()), + firstSeenMillis, + startedMillis, + completedMillis, + warnings, + errors + ).withTiming(job, now); + } + + private JobState withDiagnostic(final boolean warning, final boolean error) { + if (!warning && !error) { + return this; + } + return new JobState( + jobId, + groupName, + displayName, + status, + conclusion, + firstSeenMillis, + startedMillis, + completedMillis, + warnings + (warning ? 1 : 0), + errors + (error ? 1 : 0) + ); + } + + private JobState finish(final String runConclusion, final long now) { + if (completed()) { + return this; + } + final long firstSeen = firstSeenMillis == 0 ? now : firstSeenMillis; + final long started = startedMillis == 0 ? firstSeen : startedMillis; + return new JobState( + jobId, + groupName, + displayName, + "completed", + runConclusion, + firstSeen, + started, + now, + warnings, + errors + ); + } + + private boolean completed() { + return "completed".equals(status); + } + + private boolean running() { + return "in_progress".equals(status); + } + + private boolean failed() { + return completed() && !successful(conclusion) && !cancelled(); + } + + private boolean skipped() { + return completed() && ("skipped".equals(conclusion) || "neutral".equals(conclusion)); + } + + private boolean cancelled() { + return completed() && WorkflowRunView.cancelled(conclusion); + } + + private String duration(final long now) { + final long start = startedMillis > 0 ? startedMillis : firstSeenMillis; + final long end = completedMillis > 0 ? completedMillis : now; + if (start <= 0 || end < start) { + return ""; + } + return formatDuration(end - start); + } + + private JobState withTiming(final WorkflowRun.JobStatus job, final long now) { + final long firstSeen = firstSeenMillis == 0 ? now : firstSeenMillis; + final long started = "in_progress".equals(job.status()) && startedMillis == 0 ? now : startedMillis; + final long completed = "completed".equals(job.status()) && completedMillis == 0 ? now : completedMillis; + return new JobState( + jobId, + groupName, + displayName, + status, + conclusion, + firstSeen, + started, + completed, + warnings, + errors + ); + } + + private static JobDisplayName displayName(final WorkflowRun.JobStatus job) { + return splitJobName(displayBaseName(job)); + } + } + + private static class ToolbarAction extends DumbAwareAction { private final String text; private final BooleanSupplier visible; private final Runnable command; @@ -626,7 +991,7 @@ private interface TreeEntry { List snapshot(); } - private final class WorkflowNode implements TreeEntry { + private class WorkflowNode implements TreeEntry { private final Object lock = new Object(); private final List output = new ArrayList<>(); private final long startedMillis = System.currentTimeMillis(); @@ -669,24 +1034,10 @@ public String suffix() { @Override public Icon icon() { - if (shouldAnimate()) { - return AnimatedIcon.Default.INSTANCE; - } - if (cancelled(terminalConclusion) || jobs.values().stream().anyMatch(JobNode::cancelled)) { - return AllIcons.RunConfigurations.TestTerminated; - } - if (jobs.values().stream().anyMatch(JobNode::failed)) { - return AllIcons.General.Error; - } - if (jobs.values().stream().anyMatch(JobNode::skipped)) { - return AllIcons.RunConfigurations.TestState.Yellow2; - } - if (jobs.isEmpty() && terminal()) { - return successful(terminalConclusion) - ? AllIcons.General.GreenCheckmark - : AllIcons.General.Error; - } - return AllIcons.General.GreenCheckmark; + final Icon empty = terminal() && !successful(terminalConclusion) + ? AllIcons.General.Error + : AllIcons.General.GreenCheckmark; + return aggregateIcon(List.copyOf(jobs.values()), shouldAnimate(), empty, cancelled(terminalConclusion)); } @Override @@ -701,7 +1052,7 @@ private boolean completed() { } } - private final class GroupNode implements TreeEntry { + private class GroupNode implements TreeEntry { private final String name; private @Nullable DefaultMutableTreeNode treeNode; @@ -738,19 +1089,7 @@ public String suffix() { @Override public Icon icon() { final List children = children(); - if (children.stream().anyMatch(JobNode::running)) { - return AnimatedIcon.Default.INSTANCE; - } - if (children.stream().anyMatch(JobNode::cancelled)) { - return AllIcons.RunConfigurations.TestTerminated; - } - if (children.stream().anyMatch(JobNode::failed)) { - return AllIcons.General.Error; - } - if (children.stream().anyMatch(JobNode::skipped)) { - return AllIcons.RunConfigurations.TestState.Yellow2; - } - return children.isEmpty() ? AllIcons.RunConfigurations.TestNotRan : AllIcons.General.GreenCheckmark; + return aggregateIcon(children, children.stream().anyMatch(JobNode::running), AllIcons.RunConfigurations.TestNotRan, false); } @Override @@ -771,41 +1110,26 @@ private List children() { } } - private final class JobNode implements TreeEntry { - private final long jobId; + private class JobNode implements TreeEntry { private final Object lock = new Object(); private final List output = new ArrayList<>(); - private final WorkflowRunLogRenderer logRenderer = new WorkflowRunLogRenderer(); - private volatile String groupName; - private volatile String displayName; - private volatile String status; - private volatile String conclusion; - private volatile long firstSeenMillis; - private volatile long startedMillis; - private volatile long completedMillis; - private volatile int warnings; - private volatile int errors; + private final LogRenderer logRenderer = new LogRenderer(); + private final AtomicReference state; private @Nullable DefaultMutableTreeNode treeNode; - private JobNode(final WorkflowRunClient.JobStatus job) { - this.jobId = job.id(); - updateDisplayName(job); - this.status = job.status(); - this.conclusion = normalizeConclusion(job.conclusion()); - final long now = System.currentTimeMillis(); - this.firstSeenMillis = now; - updateTiming(job, now); + private JobNode(final WorkflowRun.JobStatus job) { + state = new AtomicReference<>(JobState.from(job, System.currentTimeMillis())); } private long jobId() { - return jobId; + return state.get().jobId(); } private String groupName() { - return groupName == null ? "" : groupName; + return state.get().groupName(); } - private void print(final WorkflowRunClient.JobStatus job, final String text, final ConsoleViewContentType contentType) { + private void print(final WorkflowRun.JobStatus job, final String text, final ConsoleViewContentType contentType) { update(job); append(new PrintedText(text, contentType)); } @@ -815,77 +1139,58 @@ private void printLog(final String text) { } private void append(final PrintedText text) { + final boolean warning = text.contentType() == ConsoleViewContentType.LOG_WARNING_OUTPUT; + final boolean error = text.contentType() == ConsoleViewContentType.LOG_ERROR_OUTPUT + || text.contentType() == ConsoleViewContentType.ERROR_OUTPUT; synchronized (lock) { output.add(text); - if (text.contentType() == ConsoleViewContentType.LOG_WARNING_OUTPUT) { - warnings++; - } - if (text.contentType() == ConsoleViewContentType.LOG_ERROR_OUTPUT || text.contentType() == ConsoleViewContentType.ERROR_OUTPUT) { - errors++; - } + } + if (warning || error) { + state.updateAndGet(current -> current.withDiagnostic(warning, error)); } printIfSelected(this, text); refreshTree(); } - private void update(final WorkflowRunClient.JobStatus job) { - updateDisplayName(job); - status = job.status(); - conclusion = normalizeConclusion(job.conclusion()); - updateTiming(job, System.currentTimeMillis()); - } - - private void updateDisplayName(final WorkflowRunClient.JobStatus job) { - final JobDisplayName parts = splitJobName(displayBaseName(job)); - groupName = parts.group(); - displayName = parts.name(); - } - - private void updateTiming(final WorkflowRunClient.JobStatus job, final long now) { - if (firstSeenMillis == 0) { - firstSeenMillis = now; - } - if ("in_progress".equals(job.status()) && startedMillis == 0) { - startedMillis = now; - } - if ("completed".equals(job.status()) && completedMillis == 0) { - completedMillis = now; - } + private void update(final WorkflowRun.JobStatus job) { + state.updateAndGet(current -> current.update(job, System.currentTimeMillis())); } @Override public String title() { - return displayName == null ? "" : displayName; + return state.get().displayName(); } @Override public String suffix() { + final JobState current = state.get(); final StringBuilder result = new StringBuilder(); - final String duration = duration(); + final String duration = current.duration(System.currentTimeMillis()); if (!duration.isBlank()) { result.append(duration); } - if (warnings > 0) { - appendSuffix(result, GitHubWorkflowBundle.message("workflow.run.tree.warn") + " " + warnings); + if (current.warnings() > 0) { + appendSuffix(result, GitHubWorkflowBundle.message("workflow.run.tree.warn") + " " + current.warnings()); } - if (errors > 0) { - appendSuffix(result, GitHubWorkflowBundle.message("workflow.run.tree.err") + " " + errors); + if (current.errors() > 0) { + appendSuffix(result, GitHubWorkflowBundle.message("workflow.run.tree.err") + " " + current.errors()); } return result.toString(); } @Override public Icon icon() { - if (completed()) { - if (skipped()) { + final JobState current = state.get(); + if (current.completed()) { + if (current.skipped()) { return AllIcons.RunConfigurations.TestState.Yellow2; } - if (cancelled()) { + if (current.cancelled()) { return AllIcons.RunConfigurations.TestTerminated; } - return successful(conclusion) ? AllIcons.General.GreenCheckmark : AllIcons.General.Error; + return successful(current.conclusion()) ? AllIcons.General.GreenCheckmark : AllIcons.General.Error; } - return running() ? AnimatedIcon.Default.INSTANCE : AllIcons.RunConfigurations.TestNotRan; + return current.running() ? AnimatedIcon.Default.INSTANCE : AllIcons.RunConfigurations.TestNotRan; } @Override @@ -896,27 +1201,27 @@ public List snapshot() { } private int warnings() { - return warnings; + return state.get().warnings(); } private int errors() { - return errors; + return state.get().errors(); } private boolean completed() { - return "completed".equals(status); + return state.get().completed(); } private boolean running() { - return "in_progress".equals(status); + return state.get().running(); } private boolean failed() { - return completed() && !successful(conclusion) && !cancelled(); + return state.get().failed(); } private boolean skipped() { - return completed() && ("skipped".equals(conclusion) || "neutral".equals(conclusion)); + return state.get().skipped(); } private boolean downloadableLog() { @@ -926,32 +1231,11 @@ private boolean downloadableLog() { } private boolean cancelled() { - return completed() && WorkflowRunConsoleTabs.cancelled(conclusion); + return state.get().cancelled(); } private void finish(final String runConclusion) { - if (completed()) { - return; - } - final long now = System.currentTimeMillis(); - if (firstSeenMillis == 0) { - firstSeenMillis = now; - } - if (startedMillis == 0) { - startedMillis = firstSeenMillis; - } - status = "completed"; - conclusion = runConclusion; - completedMillis = now; - } - - private String duration() { - final long start = startedMillis > 0 ? startedMillis : firstSeenMillis; - final long end = completedMillis > 0 ? completedMillis : System.currentTimeMillis(); - if (start <= 0 || end < start) { - return ""; - } - return formatDuration(end - start); + state.updateAndGet(current -> current.finish(runConclusion, System.currentTimeMillis())); } private void clear() { @@ -973,12 +1257,6 @@ private void printIfSelected(final TreeEntry entry, final PrintedText text) { }); } - private static String displayBaseName(final WorkflowRunClient.JobStatus job) { - return job.name() == null || job.name().isBlank() - ? GitHubWorkflowBundle.message("workflow.run.job.fallbackName", job.id()) - : job.name(); - } - private static void appendSuffix(final StringBuilder builder, final String value) { if (!builder.isEmpty()) { builder.append(" "); @@ -986,12 +1264,7 @@ private static void appendSuffix(final StringBuilder builder, final String value builder.append(value); } - private static String formatDuration(final long millis) { - final long seconds = Math.max(0, TimeUnit.MILLISECONDS.toSeconds(millis)); - return String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60); - } - - private static final class JobTreeCellRenderer extends ColoredTreeCellRenderer { + private static class JobTreeCellRenderer extends ColoredTreeCellRenderer { @Override public void customizeCellRenderer( final JTree tree, @@ -1013,9 +1286,6 @@ public void customizeCellRenderer( } } - static record JobDisplayName(String group, String name) { - } - private record PrintedText(String text, ConsoleViewContentType contentType) { } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/ClearActionCacheAction.java b/src/main/java/com/github/yunabraska/githubworkflow/services/ClearActionCacheAction.java deleted file mode 100644 index 37ff048..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/ClearActionCacheAction.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.ActionUpdateThread; -import com.intellij.openapi.actionSystem.Presentation; -import com.intellij.openapi.project.DumbAwareAction; -import org.jetbrains.annotations.NotNull; - -public final class ClearActionCacheAction extends DumbAwareAction { - - @Override - public void actionPerformed(@NotNull final AnActionEvent event) { - final GitHubActionCache.CacheSummary before = GitHubActionCache.getActionCache().summary(); - GitHubActionCache.getActionCache().clear(); - notify(event, GitHubWorkflowBundle.message("notification.cache.cleared", before.total())); - } - - @Override - public void update(@NotNull final AnActionEvent event) { - localize(event.getPresentation()); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.BGT; - } - - private static void notify(final AnActionEvent event, final String content) { - NotificationGroupManager.getInstance() - .getNotificationGroup("GitHub Workflow") - .createNotification(content, NotificationType.INFORMATION) - .notify(event.getProject()); - } - - private static void localize(final Presentation presentation) { - presentation.setText(GitHubWorkflowBundle.message("action.GitHubWorkflow.ClearActionCache.text")); - presentation.setDescription(GitHubWorkflowBundle.message("action.GitHubWorkflow.ClearActionCache.description")); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTarget.java b/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTarget.java deleted file mode 100644 index fe19fde..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTarget.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.intellij.psi.PsiElement; - -record ExpressionReferenceTarget(String kind, SimpleElement source, SimpleElement segment, PsiElement target) { -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTargets.java b/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTargets.java deleted file mode 100644 index b67f324..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/ExpressionReferenceTargets.java +++ /dev/null @@ -1,314 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.intellij.psi.PsiElement; -import org.jetbrains.yaml.psi.YAMLKeyValue; -import org.jetbrains.yaml.psi.YAMLSequenceItem; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Stream; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ENVS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_GITHUB; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ID; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_INPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_JOB; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_JOBS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_MATRIX; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_NEEDS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_PORTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUN; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SECRETS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SERVICES; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STEPS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STRATEGY; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getTextElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.removeQuotes; -import static com.github.yunabraska.githubworkflow.logic.Inputs.listInputsRaw; -import static com.github.yunabraska.githubworkflow.logic.JobContext.getService; -import static com.github.yunabraska.githubworkflow.logic.Jobs.listAllJobs; -import static com.github.yunabraska.githubworkflow.logic.Needs.getJobNeed; -import static com.github.yunabraska.githubworkflow.logic.Steps.listSteps; -import static com.github.yunabraska.githubworkflow.services.HighlightAnnotator.splitToElements; -import static com.github.yunabraska.githubworkflow.services.HighlightAnnotator.toSimpleElements; -import static java.util.Optional.ofNullable; - -final class ExpressionReferenceTargets { - - static List resolve(final PsiElement psiElement) { - return toSimpleElements(psiElement).stream() - .flatMap(source -> resolveSource(psiElement, source).stream()) - .toList(); - } - - static List resolveAt(final PsiElement psiElement, final int offsetInElement) { - return resolve(psiElement).stream() - .filter(target -> contains(target.segment(), offsetInElement)) - .toList(); - } - - static Optional segmentAt(final PsiElement psiElement, final int offsetInElement) { - return toSimpleElements(psiElement).stream() - .filter(source -> contains(source, offsetInElement)) - .flatMap(source -> Stream.of(splitToElements(source))) - .filter(segment -> contains(segment, offsetInElement)) - .findFirst(); - } - - private static boolean contains(final SimpleElement segment, final int offsetInElement) { - return segment.startIndexOffset() - 1 <= offsetInElement && offsetInElement <= segment.endIndexOffset(); - } - - private static List resolveSource(final PsiElement psiElement, final SimpleElement source) { - final SimpleElement[] parts = splitToElements(source); - if (parts.length < 2) { - return List.of(); - } - final List result = new ArrayList<>(); - switch (parts[0].text()) { - case FIELD_INPUTS -> resolveInput(psiElement, source, parts[1]).ifPresent(result::add); - case FIELD_SECRETS -> resolveSecret(psiElement, source, parts[1]).ifPresent(result::add); - case FIELD_ENVS -> resolveEnv(psiElement, source, parts[1]).ifPresent(result::add); - case FIELD_MATRIX -> resolveMatrix(psiElement, source, parts[1]).ifPresent(result::add); - case FIELD_JOB -> resolveJobContext(psiElement, source, parts).ifPresent(result::add); - case FIELD_STEPS -> { - resolveStep(psiElement, source, parts[1]).ifPresent(result::add); - resolveStepOutput(psiElement, source, parts).ifPresent(result::add); - } - case FIELD_NEEDS -> { - resolveNeed(psiElement, source, parts[1]).ifPresent(result::add); - resolveNeedOutput(psiElement, source, parts).ifPresent(result::add); - } - case FIELD_JOBS -> { - resolveJob(psiElement, source, parts[1]).ifPresent(result::add); - resolveJobOutput(psiElement, source, parts).ifPresent(result::add); - } - default -> { - // Built-in contexts without a local declaration stay validated by highlighters, but are not clickable. - } - } - return result; - } - - private static Optional resolveInput( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement inputId - ) { - return listInputsRaw(psiElement).stream() - .filter(input -> inputId.text().equals(input.getKeyText())) - .findFirst() - .map(input -> new ExpressionReferenceTarget("input", source, inputId, input)); - } - - private static Optional resolveSecret( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement secretId - ) { - return getChild(psiElement.getContainingFile(), FIELD_ON) - .stream() - .flatMap(on -> PsiElementHelper.getAllElements(on, FIELD_SECRETS).stream()) - .flatMap(secrets -> PsiElementHelper.getChildren(secrets).stream()) - .filter(secret -> secretId.text().equals(secret.getKeyText())) - .findFirst() - .map(secret -> new ExpressionReferenceTarget("secret", source, secretId, secret)); - } - - private static Optional resolveEnv( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement envId - ) { - return Stream.of(stepEnv(psiElement, envId), jobEnv(psiElement, envId), workflowEnv(psiElement, envId)) - .flatMap(Optional::stream) - .findFirst() - .map(env -> new ExpressionReferenceTarget("env", source, envId, env)); - } - - private static Optional stepEnv(final PsiElement psiElement, final SimpleElement envId) { - return PsiElementHelper.getParentStep(psiElement) - .flatMap(step -> getChild(step, FIELD_ENVS)) - .flatMap(env -> childByKey(env, envId.text())); - } - - private static Optional jobEnv(final PsiElement psiElement, final SimpleElement envId) { - return PsiElementHelper.getParentJob(psiElement) - .flatMap(job -> getChild(job, FIELD_ENVS)) - .flatMap(env -> childByKey(env, envId.text())); - } - - private static Optional workflowEnv(final PsiElement psiElement, final SimpleElement envId) { - return getChild(psiElement.getContainingFile(), FIELD_ENVS) - .flatMap(env -> childByKey(env, envId.text())); - } - - private static Optional resolveMatrix( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement matrixId - ) { - return PsiElementHelper.getParentJob(psiElement) - .flatMap(job -> getChild(job, FIELD_STRATEGY)) - .flatMap(strategy -> getChild(strategy, FIELD_MATRIX)) - .flatMap(matrix -> matrixProperty(matrix, matrixId.text())) - .map(matrix -> new ExpressionReferenceTarget("matrix", source, matrixId, matrix)); - } - - private static Optional resolveJobContext( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement[] parts - ) { - if (parts.length >= 3 && FIELD_SERVICES.equals(parts[1].text())) { - if (parts.length >= 5 && FIELD_PORTS.equals(parts[3].text())) { - return getService(psiElement, parts[2].text()) - .flatMap(service -> getChild(service, FIELD_PORTS)) - .map(ports -> new ExpressionReferenceTarget("service-port", source, parts[4], ports)); - } - return getService(psiElement, parts[2].text()) - .map(service -> new ExpressionReferenceTarget("service", source, parts[2], service)); - } - if (parts.length >= 3 && "container".equals(parts[1].text())) { - return PsiElementHelper.getParentJob(psiElement) - .flatMap(job -> getChild(job, "container")) - .map(container -> new ExpressionReferenceTarget("container", source, parts[2], container)); - } - return Optional.empty(); - } - - private static Optional matrixProperty(final YAMLKeyValue matrix, final String key) { - return Stream.concat( - PsiElementHelper.getChildren(matrix).stream() - .filter(ExpressionReferenceTargets::isDirectMatrixProperty), - getChild(matrix, "include") - .stream() - .flatMap(include -> PsiElementHelper.getChildren(include, YAMLSequenceItem.class).stream()) - .flatMap(item -> PsiElementHelper.getChildren(item).stream()) - ) - .filter(property -> key.equals(property.getKeyText())) - .findFirst(); - } - - private static boolean isDirectMatrixProperty(final YAMLKeyValue keyValue) { - final String key = keyValue.getKeyText(); - return !"include".equals(key) && !"exclude".equals(key); - } - - private static Optional resolveStep( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement stepId - ) { - return listSteps(psiElement).stream() - .map(step -> getChild(step, FIELD_ID).orElse(null)) - .filter(Objects::nonNull) - .filter(id -> getText(id).filter(stepId.text()::equals).isPresent()) - .findFirst() - .map(step -> new ExpressionReferenceTarget("step", source, stepId, step)); - } - - private static Optional resolveStepOutput( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement[] parts - ) { - if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { - return Optional.empty(); - } - return listSteps(psiElement).stream() - .filter(step -> getText(step, FIELD_ID).filter(parts[1].text()::equals).isPresent()) - .findFirst() - .flatMap(step -> stepOutputTarget(step, parts[3].text())) - .map(output -> new ExpressionReferenceTarget("step-output", source, parts[3], output)); - } - - private static Optional stepOutputTarget(final YAMLSequenceItem step, final String outputId) { - return getChild(step, FIELD_RUN) - .filter(run -> PsiElementHelper.parseOutputVariables(run).stream().anyMatch(output -> outputId.equals(output.key()))) - .map(PsiElement.class::cast) - .or(() -> getChild(step, FIELD_USES) - .filter(uses -> com.github.yunabraska.githubworkflow.logic.Action.listActionsOutputs(step).stream() - .anyMatch(output -> outputId.equals(output.key()))) - .map(PsiElement.class::cast)); - } - - private static Optional resolveNeed( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement needId - ) { - return getJobNeed(psiElement).stream() - .flatMap(need -> getTextElements(need).stream()) - .filter(need -> needId.text().equals(removeQuotes(need.getText()))) - .findFirst() - .map(need -> new ExpressionReferenceTarget("need", source, needId, need)); - } - - private static Optional resolveNeedOutput( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement[] parts - ) { - if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { - return Optional.empty(); - } - return jobById(psiElement, parts[1].text()) - .flatMap(job -> jobOutput(job, parts[3].text())) - .map(output -> new ExpressionReferenceTarget("need-output", source, parts[3], output)); - } - - private static Optional resolveJob( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement jobId - ) { - return jobById(psiElement, jobId.text()) - .map(job -> new ExpressionReferenceTarget("job", source, jobId, job)); - } - - private static Optional resolveJobOutput( - final PsiElement psiElement, - final SimpleElement source, - final SimpleElement[] parts - ) { - if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { - return Optional.empty(); - } - return jobById(psiElement, parts[1].text()) - .flatMap(job -> jobOutput(job, parts[3].text())) - .map(output -> new ExpressionReferenceTarget("job-output", source, parts[3], output)); - } - - private static Optional jobById(final PsiElement psiElement, final String jobId) { - return listAllJobs(psiElement).stream() - .filter(job -> jobId.equals(job.getKeyText())) - .findFirst(); - } - - private static Optional jobOutput(final YAMLKeyValue job, final String outputId) { - return getChild(job, FIELD_OUTPUTS) - .flatMap(outputs -> childByKey(outputs, outputId)); - } - - private static Optional childByKey(final PsiElement parent, final String key) { - return ofNullable(parent) - .stream() - .flatMap(element -> PsiElementHelper.getChildren(element).stream()) - .filter(child -> key.equals(child.getKeyText())) - .findFirst(); - } - - private ExpressionReferenceTargets() { - // static helper class - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/FileIconProvider.java b/src/main/java/com/github/yunabraska/githubworkflow/services/FileIconProvider.java deleted file mode 100644 index f87f27e..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/FileIconProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.model.GitHubSchemaProvider; -import com.intellij.icons.AllIcons; -import com.intellij.ide.IconProvider; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.Optional; - -import static com.github.yunabraska.githubworkflow.services.SchemaProvider.SCHEMA_FILE_PROVIDERS; - -public class FileIconProvider extends IconProvider { - - @Nullable - @Override - @SuppressWarnings("java:S2637") - public Icon getIcon(@NotNull final PsiElement element, final int flags) { - return Optional.of(element) - .filter(PsiFile.class::isInstance) - .map(PsiFile.class::cast) - .map(PsiFile::getVirtualFile) - .flatMap(virtualFile -> SCHEMA_FILE_PROVIDERS.stream() - .filter(GitHubSchemaProvider.class::isInstance) - .map(GitHubSchemaProvider.class::cast) - .filter(schemaProvider -> schemaProvider.isAvailable(virtualFile)) - .map(schema -> AllIcons.Vcs.Vendors.Github) - .findFirst() - ) - .orElse(null); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizations.java b/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizations.java deleted file mode 100644 index 034997e..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizations.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.ide.impl.ProjectUtil; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectManager; -import org.jetbrains.plugins.github.authentication.GHAccountsUtil; -import org.jetbrains.plugins.github.authentication.accounts.GithubAccount; -import org.jetbrains.plugins.github.util.GHCompatibilityUtil; - -import java.net.URI; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Builds authorization candidates for GitHub REST calls. - */ -final class GitHubRequestAuthorizations { - - private static final List DEFAULT_ENV_TOKENS = List.of("GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT"); - - static List forApiUrl(final String apiUrl, final String tokenEnvVar, final Project project) { - return forApiUrl(apiUrl, tokenEnvVar, project, System.getenv()); - } - - static List forApiUrl( - final String apiUrl, - final String tokenEnvVar, - final Project project, - final Map environment - ) { - final LinkedHashMap result = new LinkedHashMap<>(); - orderedAccountsFor(apiUrl).stream() - .map(account -> authorization(account, project)) - .flatMap(Optional::stream) - .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); - envAuthorizations(tokenEnvVar, environment) - .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); - result.putIfAbsent(Authorization.anonymous().key(), Authorization.anonymous()); - return List.copyOf(result.values()); - } - - static String settingsHint() { - return GitHubWorkflowBundle.message("workflow.run.auth.settings"); - } - - private static List orderedAccountsFor(final String apiUrl) { - return accounts().stream() - .sorted(Comparator - .comparingInt((GithubAccount account) -> accountPriority(account, apiUrl)) - .thenComparing(account -> account.getServer().toApiUrl()) - .thenComparing(GithubAccount::getName)) - .toList(); - } - - private static int accountPriority(final GithubAccount account, final String apiUrl) { - if (sameHost(account.getServer().toApiUrl(), apiUrl)) { - return 0; - } - return account.getServer().isGithubDotCom() ? 1 : 2; - } - - private static List accounts() { - try { - return new ArrayList<>(GHAccountsUtil.getAccounts()); - } catch (final RuntimeException ignored) { - return List.of(); - } - } - - private static Optional authorization(final GithubAccount account, final Project project) { - try { - return Optional.ofNullable(GHCompatibilityUtil.getOrRequestToken(account, project(project))) - .filter(GitHubRequestAuthorizations::hasText) - .map(token -> new Authorization(account.getName(), "Bearer " + token)); - } catch (final RuntimeException ignored) { - return Optional.empty(); - } - } - - private static List envAuthorizations(final String tokenEnvVar, final Map environment) { - final LinkedHashMap result = new LinkedHashMap<>(); - envAuthorization(tokenEnvVar, environment).ifPresent(authorization -> result.putIfAbsent(authorization.key(), authorization)); - DEFAULT_ENV_TOKENS.stream() - .filter(name -> !name.equals(tokenEnvVar)) - .map(name -> envAuthorization(name, environment)) - .flatMap(Optional::stream) - .forEach(authorization -> result.putIfAbsent(authorization.key(), authorization)); - return List.copyOf(result.values()); - } - - private static Optional envAuthorization(final String tokenEnvVar, final Map environment) { - return Optional.ofNullable(tokenEnvVar) - .map(String::trim) - .filter(GitHubRequestAuthorizations::hasText) - .flatMap(name -> Optional.ofNullable(environment.get(name)) - .filter(GitHubRequestAuthorizations::hasText) - .map(token -> new Authorization(name, "Bearer " + token))); - } - - private static Project project(final Project project) { - return Optional.ofNullable(project) - .or(() -> Optional.ofNullable(ProjectUtil.getActiveProject())) - .orElseGet(() -> ProjectManager.getInstance().getDefaultProject()); - } - - private static boolean sameHost(final String left, final String right) { - final Optional leftHost = host(left); - final Optional rightHost = host(right); - return leftHost.isPresent() && leftHost.equals(rightHost); - } - - private static Optional host(final String value) { - try { - return Optional.ofNullable(URI.create(value).getHost()) - .map(String::toLowerCase); - } catch (final RuntimeException ignored) { - return Optional.empty(); - } - } - - private static boolean hasText(final String value) { - return value != null && !value.isBlank(); - } - - record Authorization(String source, String authorizationHeader) { - - static Authorization anonymous() { - return new Authorization("anonymous", ""); - } - - boolean authenticated() { - return hasText(authorizationHeader); - } - - String key() { - return source + "|" + authorizationHeader; - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowBundle.java b/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowBundle.java deleted file mode 100644 index 0b0efcb..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowBundle.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.DynamicBundle; -import org.jetbrains.annotations.NonNls; -import org.jetbrains.annotations.PropertyKey; - -import java.text.MessageFormat; -import java.util.Locale; -import java.util.MissingResourceException; -import java.util.ResourceBundle; - -public final class GitHubWorkflowBundle { - - @NonNls - private static final String BUNDLE = "messages.GitHubWorkflowBundle"; - private static final DynamicBundle INSTANCE = new DynamicBundle(GitHubWorkflowBundle.class, BUNDLE); - - public static String message(@PropertyKey(resourceBundle = BUNDLE) final String key, final Object... params) { - final var locale = PluginSettings.maybeInstance().flatMap(PluginSettings::localeOverride); - if (locale.isPresent()) { - return messageFor(locale.get(), key, params); - } - return INSTANCE.getMessage(key, params); - } - - static String messageFor(final Locale locale, final @PropertyKey(resourceBundle = BUNDLE) String key, final Object... params) { - try { - final ResourceBundle bundle = ResourceBundle.getBundle(BUNDLE, locale); - final String pattern = bundle.getString(key); - return new MessageFormat(pattern, locale).format(params); - } catch (final MissingResourceException ignored) { - return INSTANCE.getMessage(key, params); - } - } - - private GitHubWorkflowBundle() { - // static bundle - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/HighlightAnnotator.java b/src/main/java/com/github/yunabraska/githubworkflow/services/HighlightAnnotator.java deleted file mode 100644 index 1ab6319..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/HighlightAnnotator.java +++ /dev/null @@ -1,727 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.model.IconRenderer; -import com.github.yunabraska.githubworkflow.model.NodeIcon; -import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; -import com.intellij.codeInspection.ProblemHighlightType; -import com.intellij.lang.annotation.AnnotationHolder; -import com.intellij.lang.annotation.Annotator; -import com.intellij.lang.annotation.HighlightSeverity; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.editor.DefaultLanguageHighlighterColors; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiElement; -import com.intellij.psi.impl.source.tree.LeafPsiElement; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.yaml.psi.YAMLKeyValue; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.*; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.deleteElementAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.getFirstChild; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.replaceAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.simpleTextRange; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.goToDeclarationString; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStep; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getTextElement; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.parseEnvVariables; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.parseOutputVariables; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.toYAMLKeyValue; -import static com.github.yunabraska.githubworkflow.logic.Action.highLightAction; -import static com.github.yunabraska.githubworkflow.logic.Action.highlightActionInput; -import static com.github.yunabraska.githubworkflow.logic.Envs.highLightEnvs; -import static com.github.yunabraska.githubworkflow.logic.GitHub.highLightGitea; -import static com.github.yunabraska.githubworkflow.logic.GitHub.highLightGitHub; -import static com.github.yunabraska.githubworkflow.logic.Inputs.highLightInputs; -import static com.github.yunabraska.githubworkflow.logic.JobContext.highlightJob; -import static com.github.yunabraska.githubworkflow.logic.Jobs.highLightJobs; -import static com.github.yunabraska.githubworkflow.logic.Matrix.highlightMatrix; -import static com.github.yunabraska.githubworkflow.logic.Needs.highlightNeeds; -import static com.github.yunabraska.githubworkflow.logic.Runner.highlightRunner; -import static com.github.yunabraska.githubworkflow.logic.Secrets.highLightSecrets; -import static com.github.yunabraska.githubworkflow.logic.Steps.highlightSteps; -import static com.github.yunabraska.githubworkflow.logic.Strategy.highlightStrategy; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_TEXT_VARIABLE; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.RELOAD; -import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_ON; -import static com.intellij.lang.annotation.HighlightSeverity.INFORMATION; -import static java.util.Optional.ofNullable; - -public class HighlightAnnotator implements Annotator { - - @Override - public void annotate(@NotNull final PsiElement psiElement, @NotNull final AnnotationHolder holder) { - //it's needed to handle single elements instead of bulk wise from parent. Parent elements are doesn't update so often. - if (psiElement.isValid()) { - processPsiElement(holder, psiElement); - variableElementHandler(holder, psiElement); - highlightVariableReferences(holder, psiElement); - highlightDeclarations(holder, psiElement); - highlightRunOutputs(holder, psiElement); - highlightRunnerVariables(holder, psiElement); - highlightScalarLiterals(holder, psiElement); - validateWorkflowSyntax(holder, psiElement); - // HIGHLIGHT ACTION INPUTS - highlightActionInput(holder, psiElement); - highlightNeeds(holder, psiElement); - } - } - - public static void processPsiElement(final AnnotationHolder holder, final PsiElement psiElement) { - toYAMLKeyValue(psiElement).ifPresent(element -> { - switch (element.getKeyText()) { - case FIELD_USES -> highLightAction(holder, element); - case FIELD_OUTPUTS -> outputsHandler(holder, element); - default -> { - // No Action - } - } - }); - } - - private static void highlightRunOutputs(final AnnotationHolder holder, final PsiElement psiElement) { - // SHOW Output Env && Output Variable declaration - Optional.of(psiElement) - .filter(LeafPsiElement.class::isInstance) - .map(LeafPsiElement.class::cast) - .filter(element -> PsiElementHelper.getParent(element, FIELD_RUN).isPresent()) - .ifPresent(element -> Stream.of( - parseEnvVariables(element).stream().map(variable -> withIcon(variable, ICON_ENV)).toList(), - parseOutputVariables(element).stream().map(variable -> withIcon(variable, ICON_TEXT_VARIABLE)).toList() - ).flatMap(Collection::stream).collect(Collectors.groupingBy(SimpleElement::startIndexOffset)).forEach((integer, elements) -> ofNullable(getFirstChild(elements)).ifPresent(lineElement -> holder - .newSilentAnnotation(INFORMATION) - .range(lineElement.range()) - .textAttributes(WorkflowTextAttributes.DECLARATION) - .gutterIconRenderer(new IconRenderer(null, element, lineElement.icon())) - .create() - ))); - } - - private static SimpleElement withIcon(final SimpleElement element, final NodeIcon icon) { - return new SimpleElement(element.key(), element.text(), element.range(), icon); - } - - private static void highlightRunnerVariables(final AnnotationHolder holder, final PsiElement psiElement) { - Optional.of(psiElement) - .filter(LeafPsiElement.class::isInstance) - .map(LeafPsiElement.class::cast) - .filter(element -> getParent(element, FIELD_RUN).isPresent()) - .ifPresent(element -> DEFAULT_VALUE_MAP.get(FIELD_ENVS).get().keySet().forEach(name -> highlightWord(holder, element, name, WorkflowTextAttributes.RUNNER_VARIABLE))); - } - - private static void highlightWord( - final AnnotationHolder holder, - final PsiElement element, - final String word, - final com.intellij.openapi.editor.colors.TextAttributesKey attributes - ) { - final String text = element.getText(); - int index = text.indexOf(word); - while (index >= 0) { - final int end = index + word.length(); - final boolean before = index == 0 || !isIdentifierChar(text.charAt(index - 1)); - final boolean after = end >= text.length() || !isIdentifierChar(text.charAt(end)); - if (before && after) { - holder.newSilentAnnotation(INFORMATION) - .range(new TextRange(element.getTextRange().getStartOffset() + index, element.getTextRange().getStartOffset() + end)) - .textAttributes(attributes) - .create(); - } - index = text.indexOf(word, end); - } - } - - private static void highlightScalarLiterals(final AnnotationHolder holder, final PsiElement psiElement) { - toYAMLKeyValue(psiElement) - .flatMap(PsiElementHelper::getTextElement) - .filter(text -> text.getText().matches("true|false|-?\\d+(?:\\.\\d+)?")) - .ifPresent(text -> holder.newSilentAnnotation(INFORMATION) - .range(text) - .textAttributes(WorkflowTextAttributes.SCALAR_LITERAL) - .create()); - } - - private static void validateWorkflowSyntax(final AnnotationHolder holder, final PsiElement psiElement) { - toYAMLKeyValue(psiElement) - .filter(HighlightAnnotator::shouldValidateWorkflowSyntax) - .ifPresent(element -> validateWorkflowKeyValue(holder, element)); - } - - private static boolean shouldValidateWorkflowSyntax(final YAMLKeyValue element) { - return GitHubWorkflowHelper.getWorkflowFile(element) - .filter(path -> GitHubWorkflowHelper.isWorkflowFile(path) || isUnitTestWorkflowFile(element)) - .isPresent(); - } - - private static boolean isUnitTestWorkflowFile(final YAMLKeyValue element) { - return ApplicationManager.getApplication().isUnitTestMode() - && PsiElementHelper.getChild(element.getContainingFile(), "runs").isEmpty(); - } - - private static void validateWorkflowKeyValue(final AnnotationHolder holder, final YAMLKeyValue element) { - final String key = element.getKeyText(); - final List path = yamlPath(element); - if (path.isEmpty()) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.topLevelKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_ON)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.eventKeys(), "inspection.workflow.syntax.unknownEventKey"); - return; - } - if (pathMatches(path, FIELD_ON, "workflow_dispatch")) { - validateKnownKey(holder, element, mapOf(FIELD_INPUTS), "inspection.workflow.syntax.unknownTriggerKey"); - return; - } - if (pathMatches(path, FIELD_ON, "workflow_call")) { - validateKnownKey(holder, element, mapOf(FIELD_INPUTS, FIELD_OUTPUTS, FIELD_SECRETS), "inspection.workflow.syntax.unknownTriggerKey"); - return; - } - if (isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) - || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.workflowInputPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey"); - validateWorkflowInputPropertyValue(holder, element, path); - return; - } - if (isChildOf(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.workflowOutputPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey"); - return; - } - if (isChildOf(path, FIELD_ON, "workflow_call", FIELD_SECRETS)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.workflowSecretPropertyKeys(), "inspection.workflow.syntax.unknownTriggerKey"); - if ("required".equals(key)) { - validateKnownValue(holder, element, WorkflowSyntaxSchema.booleanValues(), "inspection.workflow.syntax.unknownTriggerValue"); - } - return; - } - if (pathMatches(path, FIELD_ON, "*")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.eventFilterKeysFor(path.get(path.size() - 1)), "inspection.workflow.syntax.unknownTriggerFilter"); - if ("types".equals(key)) { - validateKnownValue(holder, element, WorkflowSyntaxSchema.eventActivityTypesFor(path.get(1)), "inspection.workflow.syntax.unknownTriggerValue"); - } - return; - } - if (pathEndsWith(path, "permissions")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.permissionScopes(), "inspection.workflow.syntax.unknownPermission"); - validateKnownValue(holder, element, WorkflowSyntaxSchema.permissionValuesFor(element.getKeyText()), "inspection.workflow.syntax.unknownPermissionValue"); - return; - } - if (pathMatches(path, "defaults", FIELD_RUN) || pathMatches(path, FIELD_JOBS, "*", "defaults", FIELD_RUN)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.defaultsRunKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, "concurrency") || pathMatches(path, FIELD_JOBS, "*", "concurrency")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.concurrencyKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_STRATEGY)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.strategyKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", "environment")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.environmentKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", "container")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.containerKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", "container", "credentials")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.credentialsKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.serviceKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*", "credentials")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.credentialsKeys(), "inspection.workflow.syntax.unknownTopLevelKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*")) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.jobKeys(), "inspection.workflow.syntax.unknownJobKey"); - return; - } - if (pathMatches(path, FIELD_JOBS, "*", FIELD_STEPS)) { - validateKnownKey(holder, element, WorkflowSyntaxSchema.stepKeys(), "inspection.workflow.syntax.unknownStepKey"); - } - } - - private static Map mapOf(final String... keys) { - final Map result = new LinkedHashMap<>(); - for (final String key : keys) { - result.put(key, key); - } - return result; - } - - private static void validateWorkflowInputPropertyValue( - final AnnotationHolder holder, - final YAMLKeyValue element, - final List path - ) { - if ("type".equals(element.getKeyText())) { - final Map allowedTypes = "workflow_call".equals(path.get(1)) - ? WorkflowSyntaxSchema.reusableWorkflowInputTypes() - : WorkflowSyntaxSchema.workflowInputTypes(); - validateKnownValue(holder, element, allowedTypes, "inspection.workflow.syntax.unknownTriggerValue"); - } - if ("required".equals(element.getKeyText())) { - validateKnownValue(holder, element, WorkflowSyntaxSchema.booleanValues(), "inspection.workflow.syntax.unknownTriggerValue"); - } - } - - private static void validateKnownKey( - final AnnotationHolder holder, - final YAMLKeyValue element, - final Map allowed, - final String messageKey - ) { - if (allowed.containsKey(element.getKeyText()) || element.getKeyText().isBlank()) { - return; - } - final TextRange range = Optional.ofNullable(element.getKey()) - .map(PsiElement::getTextRange) - .orElseGet(element::getTextRange); - final List fixes = new ArrayList<>(); - fixes.add(new SyntaxAnnotation( - GitHubWorkflowBundle.message(messageKey, element.getKeyText()), - null, - HighlightSeverity.WEAK_WARNING, - ProblemHighlightType.WEAK_WARNING, - null - )); - allowed.keySet().stream() - .map(candidate -> new SyntaxAnnotation( - GitHubWorkflowBundle.message("inspection.replace.with", candidate), - RELOAD, - HighlightSeverity.WEAK_WARNING, - ProblemHighlightType.WEAK_WARNING, - replaceAction(range, candidate) - )) - .forEach(fixes::add); - SyntaxAnnotation.createAnnotation( - element, - range, - holder, - fixes - ); - } - - private static void validateKnownValue( - final AnnotationHolder holder, - final YAMLKeyValue element, - final Map allowed, - final String messageKey - ) { - final String value = PsiElementHelper.getText(element).orElse(""); - if (allowed.isEmpty() - || value.isBlank() - || value.startsWith("${{") - || !value.matches("[A-Za-z0-9_-]+") - || allowed.containsKey(value)) { - return; - } - PsiElementHelper.getTextElement(element).ifPresent(valueElement -> { - final TextRange range = valueElement.getTextRange(); - final List fixes = new ArrayList<>(); - fixes.add(new SyntaxAnnotation( - GitHubWorkflowBundle.message(messageKey, value), - null, - HighlightSeverity.WEAK_WARNING, - ProblemHighlightType.WEAK_WARNING, - null - )); - allowed.keySet().stream() - .map(candidate -> new SyntaxAnnotation( - GitHubWorkflowBundle.message("inspection.replace.with", candidate), - RELOAD, - HighlightSeverity.WEAK_WARNING, - ProblemHighlightType.WEAK_WARNING, - replaceAction(range, candidate) - )) - .forEach(fixes::add); - SyntaxAnnotation.createAnnotation( - element, - range, - holder, - fixes - ); - }); - } - - private static List yamlPath(final YAMLKeyValue element) { - final List result = new ArrayList<>(); - PsiElement current = element.getParent(); - while (current != null && current != element.getContainingFile()) { - if (current instanceof YAMLKeyValue keyValue) { - result.add(0, keyValue.getKeyText()); - } - current = current.getParent(); - } - return result; - } - - private static boolean isChildOf(final List path, final String... expectedParent) { - if (path.size() != expectedParent.length + 1) { - return false; - } - for (int index = 0; index < expectedParent.length; index++) { - if (!expectedParent[index].equals(path.get(index))) { - return false; - } - } - return true; - } - - private static boolean pathMatches(final List path, final String... pattern) { - if (path.size() != pattern.length) { - return false; - } - for (int index = 0; index < pattern.length; index++) { - if (!"*".equals(pattern[index]) && !pattern[index].equals(path.get(index))) { - return false; - } - } - return true; - } - - private static boolean pathEndsWith(final List path, final String expected) { - return !path.isEmpty() && expected.equals(path.get(path.size() - 1)); - } - - private static void outputsHandler(final AnnotationHolder holder, final PsiElement psiElement) { - getParentJob(psiElement).ifPresent(job -> { - final List outputs = PsiElementHelper.getChildren(psiElement).stream().toList(); - final String workflowText = PsiElementHelper.getChild(psiElement.getContainingFile(), FIELD_JOBS).map(PsiElement::getText).orElse(""); - final List workflowOutputs = PsiElementHelper.getChild(psiElement.getContainingFile(), FIELD_ON) - .map(on -> getAllElements(on, FIELD_OUTPUTS)) - .map(list -> list.stream().flatMap(keyValue -> PsiElementHelper.getChildren(keyValue).stream().map(output -> getText(output, "value").orElse(""))).toList()) - .orElseGet(Collections::emptyList); - outputs.stream().filter(output -> { - final String outputKey = output.getKeyText(); - final String reusableOutputReference = FIELD_JOBS + "." + job.getKeyText() + "." + FIELD_OUTPUTS + "." + outputKey; - final String needsOutputReference = FIELD_NEEDS + "." + job.getKeyText() + "." + FIELD_OUTPUTS + "." + outputKey; - return workflowOutputs.stream().noneMatch(value -> containsOutputReference(value, reusableOutputReference)) - && !containsOutputReference(workflowText, needsOutputReference); - }).forEach(output -> new SyntaxAnnotation( - GitHubWorkflowBundle.message("inspection.output.unused", output.getKeyText()), - SUPPRESS_ON, - HighlightSeverity.WEAK_WARNING, - ProblemHighlightType.LIKE_UNUSED_SYMBOL, - deleteElementAction(output.getTextRange()), - true - ).createAnnotation(output, output.getTextRange(), holder)); - - }); - } - - private static boolean containsOutputReference(final String text, final String reference) { - int index = ofNullable(text).orElse("").indexOf(reference); - while (index >= 0) { - final int end = index + reference.length(); - if (end >= text.length() || !isIdentifierChar(text.charAt(end))) { - return true; - } - index = text.indexOf(reference, end); - } - return false; - } - - @NotNull - public static Predicate isElementWithVariables(final YAMLKeyValue parentIf) { - return element -> ofNullable(parentIf) - .or(() -> getParent(element, FIELD_RUN)) - .or(() -> getParent(element, FIELD_ID)) - .or(() -> getParent(element, "name")) - .or(() -> getParent(element, "run-name")) - .or(() -> getParent(element, "runs-on")) - .or(() -> getParent(element, "concurrency")) - .or(() -> getParent(element, "group").filter(group -> getParent(group, "concurrency").isPresent())) - .or(() -> getParent(element, "default").filter(defaultValue -> getParent(defaultValue, FIELD_INPUTS).isPresent())) - .or(() -> getParent(element, "credentials")) - .or(() -> getParent(element, "environment")) - .or(() -> getParent(element, "fail-fast").filter(failFast -> getParent(failFast, FIELD_STRATEGY).isPresent())) - .or(() -> getParent(element, "max-parallel").filter(maxParallel -> getParent(maxParallel, FIELD_STRATEGY).isPresent())) - .or(() -> getParent(element, "shell").filter(shell -> getParent(shell, "defaults").isPresent())) - .or(() -> getParent(element, "container").filter(container -> getParent(container, "jobs").isPresent())) - .or(() -> getParent(element, "url").filter(url -> getParent(url, "environment").isPresent())) - .or(() -> getParent(element, "timeout-minutes")) - .or(() -> getParent(element, "continue-on-error")) - .or(() -> getParent(element, "working-directory")) - .or(() -> getParent(element, "image").filter(image -> getParent(image, "container").isPresent() || getParent(image, "services").isPresent())) - .or(() -> getParent(element, "value").isPresent() ? getParent(element, FIELD_OUTPUTS) : Optional.empty()) - .or(() -> getParent(element, FIELD_WITH)) - .or(() -> getParent(element, FIELD_ENVS)) - .or(() -> getParent(element, FIELD_OUTPUTS)) - .isPresent(); - } - - @NotNull - public static List toSimpleElements(final PsiElement element) { - if (getParent(element, FIELD_RUN).isPresent()) { - return toSimpleElementsInExpressions(element); - } - final List result = new ArrayList<>(); - final String text = element.getText(); - int lineStart = 0; - while (lineStart <= text.length()) { - int lineEnd = text.indexOf('\n', lineStart); - if (lineEnd < 0) { - lineEnd = text.length(); - } - final String line = text.substring(lineStart, lineEnd); - if (PsiElementHelper.hasText(line) && !line.trim().startsWith("#")) { - final int currentLineStart = lineStart; - findDottedExpressions(line).stream() - .map(expression -> new SimpleElement( - expression.text(), - new TextRange( - currentLineStart + expression.range().getStartOffset(), - currentLineStart + expression.range().getEndOffset() - ) - )) - .forEach(result::add); - } - if (lineEnd == text.length()) { - break; - } - lineStart = lineEnd + 1; - } - return result; - } - - @NotNull - private static List toSimpleElementsInExpressions(final PsiElement element) { - final List result = new ArrayList<>(); - final String text = element.getText(); - int index = 0; - while (index < text.length()) { - final int expressionStart = text.indexOf("${{", index); - if (expressionStart < 0) { - break; - } - final int bodyStart = expressionStart + 3; - final int expressionEnd = text.indexOf("}}", bodyStart); - if (expressionEnd < 0) { - break; - } - final String body = text.substring(bodyStart, expressionEnd); - findDottedExpressions(body).stream() - .map(expression -> new SimpleElement( - expression.text(), - new TextRange( - bodyStart + expression.range().getStartOffset(), - bodyStart + expression.range().getEndOffset() - ) - )) - .forEach(result::add); - index = expressionEnd + 2; - } - return result; - } - - @NotNull - public static SimpleElement[] splitToElements(final SimpleElement simpleElement) { - final List result = new ArrayList<>(); - final AtomicInteger index = new AtomicInteger(0); - while (index.get() < simpleElement.text().length()) { - if (isIdentifierChar(simpleElement.text().charAt(index.get()))) { - result.add(readIdentifier(simpleElement, index)); - } else { - index.incrementAndGet(); - } - } - return result.toArray(SimpleElement[]::new); - } - - public static List findDottedExpressions(final String text) { - final List elements = new ArrayList<>(); - int index = 0; - while (index < text.length()) { - if (!isContextStart(text, index)) { - index++; - continue; - } - final int start = index; - boolean hasSeparator = false; - index = readIdentifierEnd(text, index); - while (index < text.length()) { - final char current = text.charAt(index); - if (current == '.') { - hasSeparator = true; - index++; - index = readIdentifierEnd(text, index); - } else if (current == '[') { - final int closingBracket = findClosingBracket(text, index); - if (closingBracket < 0) { - break; - } - hasSeparator = true; - index = closingBracket + 1; - } else { - break; - } - } - if (hasSeparator && start < index) { - elements.add(new SimpleElement(text.substring(start, index), new TextRange(start, index))); - } - } - return elements; - } - - private static void variableElementHandler(final AnnotationHolder holder, final PsiElement psiElement) { - final Optional parentIf = getParent(psiElement, FIELD_IF); - Optional.of(psiElement) - .filter(LeafPsiElement.class::isInstance) - .map(LeafPsiElement.class::cast) - .filter(isElementWithVariables(parentIf.orElse(null))) - .ifPresent(element -> toSimpleElements(element).forEach(simpleElement -> { - final SimpleElement[] parts = splitToElements(simpleElement); - switch (parts.length > 0 ? parts[0].text() : "N/A") { - case FIELD_INPUTS -> highLightInputs(holder, element, parts); - case FIELD_SECRETS -> - highLightSecrets(holder, psiElement, element, simpleElement, parts, parentIf.orElse(null)); - case FIELD_ENVS -> highLightEnvs(holder, element, parts); - case FIELD_GITHUB -> highLightGitHub(holder, element, parts); - case FIELD_GITEA -> highLightGitea(holder, element, parts); - case FIELD_JOB -> highlightJob(holder, element, parts); - case FIELD_RUNNER -> highlightRunner(holder, element, parts); - case FIELD_MATRIX -> highlightMatrix(holder, element, parts); - case FIELD_STRATEGY -> highlightStrategy(holder, element, parts); - case FIELD_STEPS -> highlightSteps(holder, element, parts); - case FIELD_JOBS -> highLightJobs(holder, element, parts); - case FIELD_NEEDS -> highlightNeeds(holder, element, parts); - default -> { - // ignored - } - } - }) - ); - } - - private static void highlightVariableReferences(final AnnotationHolder holder, final PsiElement psiElement) { - Optional.of(psiElement) - .filter(PsiElementHelper::isTextElement) - .ifPresent(element -> { - toSimpleElements(element).stream() - .flatMap(source -> Stream.of(splitToElements(source))) - .forEach(segment -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) - .range(simpleTextRange(element, segment)) - .textAttributes(WorkflowTextAttributes.VARIABLE_REFERENCE) - .create()); - ExpressionReferenceTargets.resolve(element).forEach(target -> { - final String tooltip = goToDeclarationString(); - holder.newSilentAnnotation(HighlightSeverity.INFORMATION) - .range(simpleTextRange(element, target.segment())) - .textAttributes(WorkflowTextAttributes.VARIABLE_REFERENCE) - .create(); - holder.newAnnotation(HighlightSeverity.INFORMATION, tooltip) - .range(simpleTextRange(element, target.segment())) - .textAttributes(DefaultLanguageHighlighterColors.HIGHLIGHTED_REFERENCE) - .tooltip(tooltip) - .create(); - }); - }); - } - - private static void highlightDeclarations(final AnnotationHolder holder, final PsiElement psiElement) { - toYAMLKeyValue(psiElement).ifPresent(element -> { - highlightJobDeclaration(holder, element); - highlightStepDeclaration(holder, element); - }); - } - - private static void highlightJobDeclaration(final AnnotationHolder holder, final YAMLKeyValue element) { - getParent(element, FIELD_JOBS) - .filter(jobs -> isDirectChildOf(element, jobs)) - .flatMap(job -> ofNullable(element.getKey())) - .ifPresent(key -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) - .range(key) - .textAttributes(WorkflowTextAttributes.DECLARATION) - .create()); - } - - private static boolean isDirectChildOf(final YAMLKeyValue child, final YAMLKeyValue parent) { - PsiElement current = child.getParent(); - while (current != null && current != parent) { - if (current instanceof YAMLKeyValue) { - return false; - } - current = current.getParent(); - } - return current == parent; - } - - private static void highlightStepDeclaration(final AnnotationHolder holder, final YAMLKeyValue element) { - if (FIELD_ID.equals(element.getKeyText()) && getParentStep(element).isPresent()) { - getTextElement(element).ifPresent(text -> holder.newSilentAnnotation(HighlightSeverity.INFORMATION) - .range(text) - .textAttributes(WorkflowTextAttributes.DECLARATION) - .create()); - } - } - - private static SimpleElement readIdentifier(final SimpleElement simpleElement, final AtomicInteger index) { - final int start = index.get(); - index.set(readIdentifierEnd(simpleElement.text(), start)); - return new SimpleElement( - simpleElement.text().substring(start, index.get()), - new TextRange(simpleElement.range().getStartOffset() + start, simpleElement.range().getStartOffset() + index.get()) - ); - } - - private static int readIdentifierEnd(final String text, final int start) { - int index = start; - while (index < text.length() && isIdentifierChar(text.charAt(index))) { - index++; - } - return index; - } - - private static int findClosingBracket(final String text, final int start) { - int index = start + 1; - while (index < text.length()) { - if (text.charAt(index) == ']') { - return index; - } - index++; - } - return -1; - } - - private static boolean isContextStart(final String text, final int start) { - return List.of(FIELD_INPUTS, FIELD_SECRETS, FIELD_ENVS, FIELD_GITHUB, FIELD_GITEA, FIELD_JOB, FIELD_RUNNER, FIELD_MATRIX, FIELD_STRATEGY, FIELD_STEPS, FIELD_JOBS, FIELD_NEEDS, FIELD_VARS) - .stream() - .anyMatch(context -> text.startsWith(context, start) && hasContextSeparator(text, start + context.length())); - } - - private static boolean hasContextSeparator(final String text, final int index) { - return index < text.length() && (text.charAt(index) == '.' || text.charAt(index) == '['); - } - - private static boolean isIdentifierChar(final char character) { - return Character.isLetterOrDigit(character) || character == '_' || character == '-'; - } - - -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitter.java b/src/main/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitter.java deleted file mode 100644 index f632092..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitter.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.ide.BrowserUtil; -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.extensions.PluginDescriptor; -import com.intellij.openapi.util.SystemInfo; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.util.Consumer; -import org.jetbrains.annotations.NonNls; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.awt.*; -import java.net.URLEncoder; -import java.util.Optional; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Optional.ofNullable; - -final class PluginErrorReportSubmitter extends ErrorReportSubmitter { - - @NonNls - private static final String REPORT_URL = "https://github.com/YunaBraska/github-workflow-plugin/issues/new?labels=bug&template=---bug-report.md"; - - @NotNull - @Override - public String getReportActionText() { - return GitHubWorkflowBundle.message("error.report.action"); - } - - @Override - public boolean submit(final IdeaLoggingEvent @NotNull [] events, - @Nullable final String additionalInfo, - @NotNull final Component parentComponent, - @NotNull final Consumer consumer) { - if (events.length == 0) { - consumer.consume(new SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.FAILED)); - return false; - } - - final IdeaLoggingEvent event = events[0]; - final String throwableText = event.getThrowableText(); - - final StringBuilder sb = new StringBuilder(REPORT_URL); - - sb.append(URLEncoder.encode(StringUtil.splitByLines(throwableText)[0], UTF_8)); - ofNullable(event.getThrowable()) - .map(Throwable::getMessage) - .or(() -> Optional.of(throwableText).map(title -> StringUtil.splitByLines(title)[0])) - .map(title -> "&title=" + URLEncoder.encode(title, UTF_8)) - .ifPresent(sb::append); - - sb.append("&body="); - sb.append(URLEncoder.encode("\n\n### " + GitHubWorkflowBundle.message("error.report.description") + "\n", UTF_8)); - sb.append(URLEncoder.encode(StringUtil.defaultIfEmpty(additionalInfo, ""), UTF_8)); - - sb.append(URLEncoder.encode("\n\n### " + GitHubWorkflowBundle.message("error.report.steps") + "\n", UTF_8)); - sb.append(URLEncoder.encode(GitHubWorkflowBundle.message("error.report.sample"), UTF_8)); - - sb.append(URLEncoder.encode("\n\n### " + GitHubWorkflowBundle.message("error.report.message") + "\n", UTF_8)); - sb.append(URLEncoder.encode(StringUtil.defaultIfEmpty(event.getMessage(), ""), UTF_8)); - - sb.append(URLEncoder.encode("\n\n### " + GitHubWorkflowBundle.message("error.report.runtime") + "\n", UTF_8)); - final PluginDescriptor descriptor = getPluginDescriptor(); - sb.append(URLEncoder.encode(GitHubWorkflowBundle.message("error.report.pluginVersion", descriptor.getVersion()) + "\n", UTF_8)); - final String ideInfo = ApplicationInfo.getInstance().getFullApplicationName() + - " (" + ApplicationInfo.getInstance().getBuild().asString() + ")"; - sb.append(URLEncoder.encode(GitHubWorkflowBundle.message("error.report.ide", ideInfo) + "\n", UTF_8)); - sb.append(URLEncoder.encode(GitHubWorkflowBundle.message("error.report.os", SystemInfo.OS_NAME + " " + SystemInfo.OS_VERSION), UTF_8)); - - sb.append(URLEncoder.encode("\n\n### " + GitHubWorkflowBundle.message("error.report.stacktrace") + "\n", UTF_8)); - sb.append(URLEncoder.encode("```\n", UTF_8)); - sb.append(URLEncoder.encode(throwableText, UTF_8)); - sb.append(URLEncoder.encode("```\n", UTF_8)); - - BrowserUtil.browse(sb.toString()); - - consumer.consume(new SubmittedReportInfo(SubmittedReportInfo.SubmissionStatus.NEW_ISSUE)); - return true; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/PluginSettings.java b/src/main/java/com/github/yunabraska/githubworkflow/services/PluginSettings.java deleted file mode 100644 index d638dde..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/PluginSettings.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.PersistentStateComponent; -import com.intellij.openapi.components.State; -import com.intellij.openapi.components.Storage; -import com.intellij.util.xmlb.XmlSerializerUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Locale; -import java.util.Optional; - -/** - * Persistent user settings for the GitHub Workflow plugin. - */ -@State(name = "GitHubWorkflowPluginSettings", storages = {@Storage("githubWorkflowPluginSettings.xml")}) -public final class PluginSettings implements PersistentStateComponent { - - public static final String SYSTEM_LANGUAGE = ""; - - public static final class StateData { - public String languageTag = SYSTEM_LANGUAGE; - } - - private final StateData state = new StateData(); - - public static PluginSettings getInstance() { - return ApplicationManager.getApplication().getService(PluginSettings.class); - } - - public static Optional maybeInstance() { - try { - return Optional.ofNullable(ApplicationManager.getApplication()) - .map(application -> application.getService(PluginSettings.class)); - } catch (final RuntimeException ignored) { - return Optional.empty(); - } - } - - @Override - public @Nullable StateData getState() { - return state; - } - - @Override - public void loadState(@NotNull final StateData state) { - XmlSerializerUtil.copyBean(state, this.state); - } - - public String languageTag() { - return state.languageTag == null ? SYSTEM_LANGUAGE : state.languageTag; - } - - public PluginSettings languageTag(final String languageTag) { - state.languageTag = languageTag == null ? SYSTEM_LANGUAGE : languageTag.trim(); - return this; - } - - public Optional localeOverride() { - final String languageTag = languageTag(); - return languageTag.isBlank() ? Optional.empty() : Optional.of(Locale.forLanguageTag(languageTag.replace('_', '-'))); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/ProjectStartup.java b/src/main/java/com/github/yunabraska/githubworkflow/services/ProjectStartup.java deleted file mode 100644 index 3a59a74..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/ProjectStartup.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.helper.ListenerService; -import com.github.yunabraska.githubworkflow.helper.PsiElementChangeListener; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.model.GitHubAction; -import com.intellij.openapi.Disposable; -import com.intellij.openapi.application.ReadAction; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.fileEditor.FileEditorManagerListener; -import com.intellij.openapi.project.DumbService; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.startup.ProjectActivity; -import com.intellij.openapi.util.Disposer; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiManager; -import com.intellij.util.concurrency.AppExecutorUtil; -import com.intellij.util.messages.MessageBusConnection; -import kotlin.Unit; -import kotlin.coroutines.Continuation; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; -import static com.intellij.openapi.util.io.NioFiles.toPath; - - -public class ProjectStartup implements ProjectActivity { - - @Nullable - @Override - public Object execute(@NotNull final Project project, @NotNull final Continuation continuation) { - final Disposable listenerDisposable = Disposer.newDisposable(); - Disposer.register(ListenerService.getInstance(project), listenerDisposable); - - // ON PsiElement Change - PsiManager.getInstance(project).addPsiTreeChangeListener(new PsiElementChangeListener(), listenerDisposable); - - // AFTER STARTUP - final FileEditorManager fileEditorManager = FileEditorManager.getInstance(project); - for (final VirtualFile openedFile : fileEditorManager.getOpenFiles()) { - asyncInitAllActions(project, openedFile); - } - - final MessageBusConnection connection = project.getMessageBus().connect(listenerDisposable); - connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() { - @Override - public void fileOpened(@NotNull final FileEditorManager source, @NotNull final VirtualFile file) { - asyncInitAllActions(project, file); - } - }); - - - // CLEANUP ACTION CACHE SCHEDULER - final ScheduledFuture cleanupTask = AppExecutorUtil.getAppScheduledExecutorService() - .scheduleWithFixedDelay(() -> getActionCache().cleanUp(), 0, 30, TimeUnit.MINUTES); - - // Ensure the executor is shut down when the project is disposed - Disposer.register(ListenerService.getInstance(project), () -> { - cleanupTask.cancel(false); - }); - return null; - } - - private static void asyncInitAllActions(final Project project, final VirtualFile virtualFile) { - final Runnable task = () -> { - if (virtualFile != null && virtualFile.isValid() && (GitHubWorkflowHelper.isWorkflowPath(toPath(virtualFile.getPath())))) { - ReadAction.nonBlocking(() -> unresolvedActions(project, virtualFile)) - .inSmartMode(project) - .submit(AppExecutorUtil.getAppExecutorService()) - .onSuccess(GitHubActionCache::resolveActionsAsync); - } - }; - - threadPoolExec(project, task); - } - - private static List unresolvedActions(final Project project, final VirtualFile virtualFile) { - final List actions = new ArrayList<>(); - Optional.of(PsiManager.getInstance(project)) - .map(psiManager -> psiManager.findFile(virtualFile)) - .map(psiFile -> PsiElementHelper.getAllElements(psiFile, FIELD_USES)) - .ifPresent(usesList -> usesList.stream() - .map(GitHubActionCache::getAction) - .filter(Objects::nonNull) - .filter(action -> !action.isSuppressed()) - .filter(action -> !action.isResolved()) - .forEach(actions::add)); - return actions; - } - - public static void threadPoolExec(final Project project, final Runnable task) { - if (!DumbService.isDumb(project)) { - AppExecutorUtil.getAppExecutorService().execute(task); - } else { - DumbService.getInstance(project).runWhenSmart(() -> AppExecutorUtil.getAppExecutorService().execute(task)); - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/ReferenceContributor.java b/src/main/java/com/github/yunabraska/githubworkflow/services/ReferenceContributor.java deleted file mode 100644 index 55f91b5..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/ReferenceContributor.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.github.yunabraska.githubworkflow.model.GitHubAction; -import com.github.yunabraska.githubworkflow.model.VariableReferenceResolver; -import com.intellij.openapi.util.Key; -import com.intellij.openapi.util.TextRange; -import com.intellij.patterns.PlatformPatterns; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiReference; -import com.intellij.psi.PsiReferenceContributor; -import com.intellij.psi.PsiReferenceProvider; -import com.intellij.psi.PsiReferenceRegistrar; -import com.intellij.util.ProcessingContext; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.removeQuotes; -import static com.github.yunabraska.githubworkflow.logic.Action.referenceGithubAction; -import static com.github.yunabraska.githubworkflow.logic.Needs.referenceNeeds; - -public class ReferenceContributor extends PsiReferenceContributor { - - public static final Key ACTION_KEY = new Key<>("ACTION_KEY"); - - @Override - public void registerReferenceProviders(@NotNull final PsiReferenceRegistrar registrar) { - registrar.registerReferenceProvider( - PlatformPatterns.psiElement(PsiElement.class), - new PsiReferenceProvider() { - @NotNull - @Override - public PsiReference @NotNull [] getReferencesByElement( - @NotNull final PsiElement psiElement, - @NotNull final ProcessingContext context - ) { - return getWorkflowFile(psiElement).isEmpty() ? PsiReference.EMPTY_ARRAY : textElement(psiElement) - .flatMap(element -> { - final String text = removeQuotes(element.getText().replace("IntellijIdeaRulezzz ", "").replace("IntellijIdeaRulezzz", "")); - return referenceGithubAction(element) - .or(() -> referenceNeeds(element, text)) - .or(() -> referenceVariables(element)); - } - ) - .orElse(PsiReference.EMPTY_ARRAY); - } - } - ); - } - - private static Optional textElement(final PsiElement psiElement) { - PsiElement current = psiElement; - while (current != null && current.getParent() != current) { - if (PsiElementHelper.isTextElement(current)) { - return Optional.of(current); - } - current = current.getParent(); - } - return Optional.empty(); - } - - private static Optional referenceVariables(final PsiElement psiElement) { - final PsiReference[] references = ExpressionReferenceTargets.resolve(psiElement).stream() - .map(target -> new VariableReferenceResolver( - psiElement, - new TextRange(target.segment().startIndexOffset(), target.segment().endIndexOffset()), - target.target() - )) - .toArray(PsiReference[]::new); - if (references.length == 0) { - return Optional.empty(); - } - return Optional.of(references); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/RefreshActionCacheAction.java b/src/main/java/com/github/yunabraska/githubworkflow/services/RefreshActionCacheAction.java deleted file mode 100644 index 1e97e39..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/RefreshActionCacheAction.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.ActionUpdateThread; -import com.intellij.openapi.actionSystem.Presentation; -import com.intellij.openapi.project.DumbAwareAction; -import org.jetbrains.annotations.NotNull; - -public final class RefreshActionCacheAction extends DumbAwareAction { - - @Override - public void actionPerformed(@NotNull final AnActionEvent event) { - final GitHubActionCache.CacheSummary before = GitHubActionCache.getActionCache().summary(); - GitHubActionCache.getActionCache().refreshResolvedRemoteActions(); - notify(event, GitHubWorkflowBundle.message("notification.cache.refresh.started", before.remote())); - } - - @Override - public void update(@NotNull final AnActionEvent event) { - localize(event.getPresentation()); - final GitHubActionCache.CacheSummary summary = GitHubActionCache.getActionCache().summary(); - event.getPresentation().setEnabled(summary.remote() > 0); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.BGT; - } - - private static void notify(final AnActionEvent event, final String content) { - NotificationGroupManager.getInstance() - .getNotificationGroup("GitHub Workflow") - .createNotification(content, NotificationType.INFORMATION) - .notify(event.getProject()); - } - - private static void localize(final Presentation presentation) { - presentation.setText(GitHubWorkflowBundle.message("action.GitHubWorkflow.RefreshActionCache.text")); - presentation.setDescription(GitHubWorkflowBundle.message("action.GitHubWorkflow.RefreshActionCache.description")); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionProviders.java b/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionProviders.java deleted file mode 100644 index a0851c9..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionProviders.java +++ /dev/null @@ -1,337 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.intellij.openapi.diagnostic.Logger; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public final class RemoteActionProviders { - - private static final Logger LOG = Logger.getInstance(RemoteActionProviders.class); - private static final HttpClient CLIENT = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(2)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - - public static Optional resolve(final String usesValue) { - return RemoteServerSettings.getInstance().enabledServers().stream() - .map(server -> resolve(server, usesValue)) - .flatMap(Optional::stream) - .findFirst(); - } - - public static List latestRefs(final String usesBase, final int limit) { - if (limit < 1) { - return List.of(); - } - return RemoteServerSettings.getInstance().enabledServers().stream() - .map(server -> RemoteUses.parseBase(server, usesBase) - .map(uses -> latestRefs(server, uses, limit)) - .orElseGet(List::of)) - .filter(refs -> !refs.isEmpty()) - .findFirst() - .orElseGet(List::of); - } - - public static Map searchUses(final String usesPrefix, final int limit) { - if (limit < 1) { - return Map.of(); - } - return RemoteServerSettings.getInstance().enabledServers().stream() - .map(server -> RemoteUsesPrefix.parse(server, usesPrefix) - .map(prefix -> searchUses(server, prefix, limit)) - .orElseGet(Map::of)) - .filter(items -> !items.isEmpty()) - .findFirst() - .orElseGet(Map::of); - } - - private static Optional resolve(final RemoteServerSettings.Server server, final String usesValue) { - return RemoteUses.parse(server, usesValue).flatMap(remoteUses -> resolve(server, remoteUses)); - } - - private static Optional resolve(final RemoteServerSettings.Server server, final RemoteUses uses) { - for (final String metadataPath : metadataPaths(server, uses)) { - final Optional content = getContent(server, uses.owner(), uses.repo(), metadataPath, uses.ref()); - if (content.isPresent()) { - final List refs = listRefs(server, uses.owner(), uses.repo()); - return Optional.of(new RemoteActionResolution( - uses.usesValue(), - uses.owner() + "/" + uses.repo(), - content.get().downloadUrl(), - htmlUrl(server, uses, metadataPath), - content.get().content(), - !isWorkflowPath(metadataPath), - refs - )); - } - } - return Optional.empty(); - } - - private static List metadataPaths(final RemoteServerSettings.Server server, final RemoteUses uses) { - if (isWorkflowPath(uses.path())) { - return List.of(uses.path()); - } - final String base = uses.path().isBlank() ? "" : uses.path() + "/"; - return List.of(base + "action.yml", base + "action.yaml"); - } - - private static boolean isWorkflowPath(final String path) { - final String normalized = path.replace('\\', '/'); - return normalized.contains(".github/workflows/") - && (normalized.endsWith(".yml") || normalized.endsWith(".yaml")); - } - - private static Optional getContent( - final RemoteServerSettings.Server server, - final String owner, - final String repo, - final String path, - final String ref - ) { - final String url = server.apiUrl + "/repos/" + encode(owner) + "/" + encode(repo) + "/contents/" + encodePath(path) + "?ref=" + encode(ref); - return getJson(server, url).flatMap(json -> contentFromJson(json, url)); - } - - private static List listRefs(final RemoteServerSettings.Server server, final String owner, final String repo) { - final LinkedHashSet result = new LinkedHashSet<>(); - for (final String endpoint : List.of("branches", "tags")) { - final String url = server.apiUrl + "/repos/" + encode(owner) + "/" + encode(repo) + "/" + endpoint; - getJson(server, url).ifPresent(json -> namesFromJson(json).forEach(result::add)); - } - return List.copyOf(result); - } - - private static List latestRefs(final RemoteServerSettings.Server server, final RemoteUses uses, final int limit) { - final LinkedHashSet result = new LinkedHashSet<>(); - for (final String endpoint : List.of("tags", "branches")) { - final String url = server.apiUrl + "/repos/" + encode(uses.owner()) + "/" + encode(uses.repo()) + "/" + endpoint + "?per_page=" + limit; - getJson(server, url).ifPresent(json -> namesFromJson(json).forEach(result::add)); - if (result.size() >= limit) { - break; - } - } - return result.stream().limit(limit).toList(); - } - - private static Map searchUses(final RemoteServerSettings.Server server, final RemoteUsesPrefix prefix, final int limit) { - final Map result = new LinkedHashMap<>(); - for (final String endpoint : List.of("users", "orgs")) { - final String url = server.apiUrl + "/" + endpoint + "/" + encode(prefix.owner()) + "/repos?per_page=" + limit; - getJson(server, url).ifPresent(json -> repoCompletionsFromJson(json, prefix, limit).forEach(result::putIfAbsent)); - if (result.size() >= limit) { - break; - } - } - return result.entrySet().stream() - .limit(limit) - .collect(LinkedHashMap::new, (map, entry) -> map.put(entry.getKey(), entry.getValue()), LinkedHashMap::putAll); - } - - private static Optional getJson(final RemoteServerSettings.Server server, final String url) { - for (final GitHubRequestAuthorizations.Authorization authorization : GitHubRequestAuthorizations.forApiUrl(server.apiUrl, server.tokenEnvVar, null)) { - try { - final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) - .timeout(Duration.ofSeconds(3)) - .header("Accept", "application/json") - .header("User-Agent", "GitHub-Workflow-Plugin"); - if (authorization.authenticated()) { - builder.header("Authorization", authorization.authorizationHeader()); - } - final HttpResponse response = CLIENT.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - if (response.statusCode() / 100 == 2) { - return Optional.of(JsonParser.parseString(response.body())); - } - if (!shouldTryNextAuthorization(response.statusCode())) { - return Optional.empty(); - } - } catch (final IOException exception) { - LOG.warn("Remote request failed [" + url + "]", exception); - return Optional.empty(); - } catch (final InterruptedException exception) { - Thread.currentThread().interrupt(); - return Optional.empty(); - } catch (final RuntimeException exception) { - LOG.warn("Remote response failed [" + url + "]", exception); - return Optional.empty(); - } - } - return Optional.empty(); - } - - private static boolean shouldTryNextAuthorization(final int statusCode) { - return statusCode == 401 || statusCode == 403 || statusCode == 404 || statusCode == 429; - } - - private static Optional contentFromJson(final JsonElement json, final String fallbackDownloadUrl) { - if (!json.isJsonObject()) { - return Optional.empty(); - } - final JsonObject object = json.getAsJsonObject(); - final Optional rawContent = stringValue(object, "content"); - if (rawContent.isEmpty()) { - return Optional.empty(); - } - final String content = new String(Base64.getMimeDecoder().decode(rawContent.get()), StandardCharsets.UTF_8); - final String downloadUrl = stringValue(object, "download_url").orElse(fallbackDownloadUrl); - return Optional.of(new ContentResponse(content, downloadUrl)); - } - - private static List namesFromJson(final JsonElement json) { - final List result = new ArrayList<>(); - if (json.isJsonArray()) { - final JsonArray array = json.getAsJsonArray(); - for (final JsonElement element : array) { - if (element.isJsonObject()) { - stringValue(element.getAsJsonObject(), "name").ifPresent(result::add); - } - } - } - return result; - } - - private static Map repoCompletionsFromJson(final JsonElement json, final RemoteUsesPrefix prefix, final int limit) { - final Map result = new LinkedHashMap<>(); - if (json.isJsonArray()) { - final JsonArray array = json.getAsJsonArray(); - for (final JsonElement element : array) { - if (element.isJsonObject()) { - final JsonObject object = element.getAsJsonObject(); - final Optional name = stringValue(object, "name"); - final Optional fullName = stringValue(object, "full_name"); - if (name.filter(value -> value.startsWith(prefix.repoPrefix())).isPresent()) { - result.putIfAbsent( - fullName.orElse(prefix.owner() + "/" + name.get()), - stringValue(object, "description").orElse(GitHubWorkflowBundle.message("completion.remote.repository")) - ); - } - } - if (result.size() >= limit) { - break; - } - } - } - return result; - } - - private static Optional stringValue(final JsonObject object, final String name) { - return Optional.ofNullable(object.get(name)) - .filter(JsonElement::isJsonPrimitive) - .map(JsonElement::getAsString) - .filter(value -> !value.isBlank()); - } - - private static String htmlUrl(final RemoteServerSettings.Server server, final RemoteUses uses, final String metadataPath) { - final String base = server.webUrl + "/" + uses.owner() + "/" + uses.repo(); - if (isWorkflowPath(metadataPath)) { - return base + "/blob/" + uses.ref() + "/" + metadataPath; - } - final String actionPath = metadataPath.endsWith("/action.yml") - ? metadataPath.substring(0, metadataPath.length() - "/action.yml".length()) - : metadataPath.endsWith("/action.yaml") - ? metadataPath.substring(0, metadataPath.length() - "/action.yaml".length()) - : ""; - final String suffix = actionPath.isBlank() ? "" : "/" + actionPath; - return base + "/tree/" + uses.ref() + suffix + "#readme"; - } - - private static String encodePath(final String path) { - return List.of(path.split("/")).stream().map(RemoteActionProviders::encode).reduce((left, right) -> left + "/" + right).orElse(""); - } - - private static String encode(final String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); - } - - private record ContentResponse(String content, String downloadUrl) { - } - - private record RemoteUses(String usesValue, String owner, String repo, String path, String ref) { - - static Optional parse(final RemoteServerSettings.Server server, final String value) { - if (value == null || value.isBlank() || value.startsWith(".")) { - return Optional.empty(); - } - final String stripped = stripServerPrefix(server, value.trim()).orElse(null); - if (stripped == null) { - return Optional.empty(); - } - final int atIndex = stripped.lastIndexOf('@'); - if (atIndex < 0 || atIndex == stripped.length() - 1) { - return Optional.empty(); - } - final String path = stripped.substring(0, atIndex); - final String ref = stripped.substring(atIndex + 1); - final String[] parts = path.split("/", 3); - if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { - return Optional.empty(); - } - return Optional.of(new RemoteUses(value.trim(), parts[0], parts[1], parts.length == 3 ? parts[2] : "", ref)); - } - - static Optional parseBase(final RemoteServerSettings.Server server, final String value) { - if (value == null || value.isBlank() || value.startsWith(".")) { - return Optional.empty(); - } - final String stripped = stripServerPrefix(server, value.trim()).orElse(null); - if (stripped == null) { - return Optional.empty(); - } - final int atIndex = stripped.lastIndexOf('@'); - final String path = atIndex < 0 ? stripped : stripped.substring(0, atIndex); - final String[] parts = path.split("/", 3); - if (parts.length < 2 || parts[0].isBlank() || parts[1].isBlank()) { - return Optional.empty(); - } - return Optional.of(new RemoteUses(value.trim(), parts[0], parts[1], parts.length == 3 ? parts[2] : "", "")); - } - - private static Optional stripServerPrefix(final RemoteServerSettings.Server server, final String value) { - if (value.startsWith("http://") || value.startsWith("https://")) { - final String prefix = server.webUrl + "/"; - return value.startsWith(prefix) ? Optional.of(value.substring(prefix.length())) : Optional.empty(); - } - return Optional.of(value); - } - } - - private record RemoteUsesPrefix(String owner, String repoPrefix) { - - static Optional parse(final RemoteServerSettings.Server server, final String value) { - if (value == null || value.isBlank() || value.startsWith(".") || value.contains("@")) { - return Optional.empty(); - } - final String stripped = RemoteUses.stripServerPrefix(server, value.trim()).orElse(null); - if (stripped == null) { - return Optional.empty(); - } - final String[] parts = stripped.split("/", 3); - if (parts.length < 2 || parts[0].isBlank()) { - return Optional.empty(); - } - return Optional.of(new RemoteUsesPrefix(parts[0], parts[1])); - } - } - - private RemoteActionProviders() { - // static helper - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionResolution.java b/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionResolution.java deleted file mode 100644 index 548e89e..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteActionResolution.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import java.util.List; - -public record RemoteActionResolution( - String usesValue, - String name, - String downloadUrl, - String githubUrl, - String content, - boolean action, - List refs -) { -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteServerSettings.java b/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteServerSettings.java deleted file mode 100644 index 6252db9..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/RemoteServerSettings.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.application.ApplicationManager; -import org.jetbrains.plugins.github.authentication.GHAccountsUtil; -import org.jetbrains.plugins.github.authentication.accounts.GithubAccount; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CopyOnWriteArrayList; - -public final class RemoteServerSettings { - - public static final String TYPE_GITHUB = "github"; - - private final CopyOnWriteArrayList testServers = new CopyOnWriteArrayList<>(); - - public static RemoteServerSettings getInstance() { - return ApplicationManager.getApplication().getService(RemoteServerSettings.class); - } - - public List enabledServers() { - final Map result = new LinkedHashMap<>(); - testServers.stream() - .map(Server::normalized) - .filter(Server::isValid) - .forEach(server -> result.put(server.key(), server)); - jetBrainsGithubServers().forEach(server -> result.putIfAbsent(server.key(), server)); - final Server defaultGitHub = defaultGitHub(); - result.putIfAbsent(defaultGitHub.key(), defaultGitHub); - return List.copyOf(result.values()); - } - - void setCustomServers(final List servers) { - testServers.clear(); - Optional.ofNullable(servers).orElseGet(List::of).stream() - .map(Server::normalized) - .filter(Server::isValid) - .forEach(testServers::add); - } - - public static Server defaultGitHub() { - return new Server("GitHub", "https://github.com", "https://api.github.com", "", true); - } - - private static List jetBrainsGithubServers() { - try { - return GHAccountsUtil.getAccounts().stream() - .sorted((left, right) -> { - final int order = Integer.compare(accountOrder(left), accountOrder(right)); - return order == 0 ? left.getName().compareTo(right.getName()) : order; - }) - .map(account -> new Server( - account.getName(), - account.getServer().toUrl(), - account.getServer().toApiUrl(), - "", - true - )) - .map(Server::normalized) - .filter(Server::isValid) - .toList(); - } catch (final RuntimeException ignored) { - return List.of(); - } - } - - private static int accountOrder(final GithubAccount account) { - return account.getServer().isGithubDotCom() ? 0 : 1; - } - - public static final class Server { - public final String type; - public final String name; - public final String webUrl; - public final String apiUrl; - public final String tokenEnvVar; - public final boolean enabled; - - public Server( - final String name, - final String webUrl, - final String apiUrl, - final String tokenEnvVar, - final boolean enabled - ) { - this.type = TYPE_GITHUB; - this.name = name; - this.webUrl = webUrl; - this.apiUrl = apiUrl; - this.tokenEnvVar = tokenEnvVar; - this.enabled = enabled; - } - - public boolean isEnabled() { - return enabled; - } - - public boolean isValid() { - return isEnabled() && hasText(webUrl) && hasText(apiUrl); - } - - public String authorizationHeader() { - return Optional.ofNullable(tokenEnvVar) - .filter(RemoteServerSettings::hasText) - .map(System::getenv) - .filter(RemoteServerSettings::hasText) - .map(token -> "Bearer " + token) - .orElse(""); - } - - public Server normalized() { - return new Server( - hasText(name) ? name.trim() : webUrl, - trimTrailingSlash(webUrl), - trimTrailingSlash(apiUrl), - Optional.ofNullable(tokenEnvVar).map(String::trim).orElse(""), - enabled - ); - } - - private String key() { - final Server normalized = normalized(); - return normalized.type + "|" + normalized.webUrl + "|" + normalized.apiUrl; - } - } - - private static String trimTrailingSlash(final String value) { - final String trimmed = Optional.ofNullable(value).map(String::trim).orElse(""); - return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; - } - - private static boolean hasText(final String value) { - return value != null && !value.isBlank(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/RestoreActionWarningsAction.java b/src/main/java/com/github/yunabraska/githubworkflow/services/RestoreActionWarningsAction.java deleted file mode 100644 index 8a89844..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/RestoreActionWarningsAction.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.ActionUpdateThread; -import com.intellij.openapi.actionSystem.Presentation; -import com.intellij.openapi.project.DumbAwareAction; -import org.jetbrains.annotations.NotNull; - -public final class RestoreActionWarningsAction extends DumbAwareAction { - - @Override - public void actionPerformed(@NotNull final AnActionEvent event) { - final long restored = GitHubActionCache.getActionCache().restoreWarnings(); - notify(event, GitHubWorkflowBundle.message("notification.warnings.restored", restored)); - } - - @Override - public void update(@NotNull final AnActionEvent event) { - localize(event.getPresentation()); - final GitHubActionCache.CacheSummary summary = GitHubActionCache.getActionCache().summary(); - event.getPresentation().setEnabled(summary.suppressed() > 0); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.BGT; - } - - private static void notify(final AnActionEvent event, final String content) { - NotificationGroupManager.getInstance() - .getNotificationGroup("GitHub Workflow") - .createNotification(content, NotificationType.INFORMATION) - .notify(event.getProject()); - } - - private static void localize(final Presentation presentation) { - presentation.setText(GitHubWorkflowBundle.message("action.GitHubWorkflow.RestoreActionWarnings.text")); - presentation.setDescription(GitHubWorkflowBundle.message("action.GitHubWorkflow.RestoreActionWarnings.description")); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/SchemaProvider.java b/src/main/java/com/github/yunabraska/githubworkflow/services/SchemaProvider.java deleted file mode 100644 index dab0c95..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/SchemaProvider.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.model.GitHubSchemaProvider; -import com.intellij.openapi.project.Project; -import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider; -import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.stream.Stream; - -public class SchemaProvider implements JsonSchemaProviderFactory { - - protected static final List SCHEMA_FILE_PROVIDERS = Stream.of( - new GitHubSchemaProvider("dependabot-2.0", "Dependabot [Auto]", GitHubWorkflowHelper::isDependabotFile), - new GitHubSchemaProvider("github-action", "GitHub Action [Auto]", GitHubWorkflowHelper::isActionFile), - new GitHubSchemaProvider("github-funding", "GitHub Funding [Auto]", GitHubWorkflowHelper::isFoundingFile), - new GitHubSchemaProvider("github-workflow", "GitHub Workflow [Auto]", GitHubWorkflowHelper::isWorkflowFile), - new GitHubSchemaProvider("github-discussion", "GitHub Discussion [Auto]", GitHubWorkflowHelper::isDiscussionFile), - new GitHubSchemaProvider("github-issue-forms", "GitHub Issue Forms [Auto]", GitHubWorkflowHelper::isIssueForms), - new GitHubSchemaProvider("github-issue-config", "GitHub Workflow Issue Template configuration [Auto]", GitHubWorkflowHelper::isIssueConfigFile), - new GitHubSchemaProvider("github-workflow-template-properties", "GitHub Workflow Template Properties [Auto]", GitHubWorkflowHelper::isWorkflowTemplatePropertiesFile) - ) - .distinct() - .toList(); - - @NotNull - @Override - public List getProviders(@NotNull final Project project) { - return SCHEMA_FILE_PROVIDERS; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupEnterHandler.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupEnterHandler.java deleted file mode 100644 index 44614d2..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupEnterHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegateAdapter; -import com.intellij.openapi.actionSystem.DataContext; -import com.intellij.openapi.editor.Editor; -import com.intellij.psi.PsiFile; -import org.jetbrains.annotations.NotNull; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; - -/** - * Opens workflow key completion after pressing Enter below YAML mapping keys. - */ -public final class WorkflowAutoPopupEnterHandler extends EnterHandlerDelegateAdapter { - - @Override - public @NotNull Result postProcessEnter( - @NotNull final PsiFile file, - @NotNull final Editor editor, - @NotNull final DataContext dataContext - ) { - if (shouldAutoPopupAfterEnter(editor, file)) { - WorkflowAutoPopupTypedHandler.scheduleWorkflowPopup(file.getProject(), editor); - } - return Result.Continue; - } - - static boolean shouldAutoPopupAfterEnter(final Editor editor, final PsiFile file) { - if (editor == null || file == null || getWorkflowFile(file).isEmpty()) { - return false; - } - final String textBeforeCaret = editor.getDocument() - .getImmutableCharSequence() - .subSequence(0, Math.min(editor.getCaretModel().getOffset(), editor.getDocument().getTextLength())) - .toString(); - final int currentLineStart = textBeforeCaret.lastIndexOf('\n'); - if (currentLineStart <= 0) { - return false; - } - final int previousLineStart = textBeforeCaret.lastIndexOf('\n', currentLineStart - 1) + 1; - final String previousLine = textBeforeCaret.substring(previousLineStart, currentLineStart).trim(); - return !previousLine.startsWith("#") && previousLine.endsWith(":"); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupTypedHandler.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupTypedHandler.java deleted file mode 100644 index 1ef4d6e..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowAutoPopupTypedHandler.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.codeInsight.AutoPopupController; -import com.intellij.codeInsight.editorActions.TypedHandlerDelegate; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.project.Project; -import com.intellij.psi.PsiDocumentManager; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; - -/** - * Opens workflow completion while the user types YAML structure and expression separators. - */ -public final class WorkflowAutoPopupTypedHandler extends TypedHandlerDelegate { - - @Override - public @NotNull Result checkAutoPopup( - final char typeChar, - @NotNull final Project project, - @NotNull final Editor editor, - @NotNull final PsiFile file - ) { - // Structural workflow completion is scheduled after the typed character lands in the document. - return Result.CONTINUE; - } - - @Override - public @NotNull Result charTyped( - final char typeChar, - @NotNull final Project project, - @NotNull final Editor editor, - @NotNull final PsiFile file - ) { - if (shouldAutoPopup(typeChar, editor, file)) { - scheduleWorkflowPopup(project, editor); - } - return Result.CONTINUE; - } - - static void scheduleWorkflowPopup(final Project project, final Editor editor) { - ApplicationManager.getApplication().invokeLater(() -> { - if (project.isDisposed() || editor.isDisposed()) { - return; - } - final PsiDocumentManager documentManager = PsiDocumentManager.getInstance(project); - documentManager.commitDocument(editor.getDocument()); - final PsiFile file = documentManager.getPsiFile(editor.getDocument()); - if (file != null && getWorkflowFile(file).isPresent()) { - AutoPopupController.getInstance(project).scheduleAutoPopup(editor); - } - }); - } - - static boolean shouldAutoPopup(final char typeChar, final Editor editor, final PsiFile file) { - if (!CodeCompletion.workflowCompletionTrigger(typeChar) || editor == null || file == null) { - return false; - } - final int textLength = file.getTextLength(); - if (textLength <= 0) { - return getWorkflowFile(file).isPresent(); - } - final int offset = Math.max(0, Math.min(editor.getCaretModel().getOffset(), textLength - 1)); - final PsiElement element = Optional.ofNullable(file.findElementAt(offset)).orElse(file); - return getWorkflowFile(element).isPresent(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionConfidence.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionConfidence.java deleted file mode 100644 index 29122ef..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionConfidence.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.codeInsight.completion.CompletionConfidence; -import com.intellij.openapi.editor.Editor; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import com.intellij.util.ThreeState; -import org.jetbrains.annotations.NotNull; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper.getWorkflowFile; - -/** - * Keeps workflow auto-popup completion available in sparse YAML positions, such as the line after {@code on:}. - */ -public final class WorkflowCompletionConfidence extends CompletionConfidence { - - @Override - public @NotNull ThreeState shouldSkipAutopopup( - final Editor editor, - final PsiElement contextElement, - final PsiFile psiFile, - final int offset - ) { - return getWorkflowFile(psiFile).isPresent() || getWorkflowFile(contextElement).isPresent() - ? ThreeState.NO - : ThreeState.UNSURE; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolver.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolver.java deleted file mode 100644 index 1dc30dc..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolver.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectUtil; -import com.intellij.openapi.vfs.VirtualFile; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; - -/** - * Resolves the checked-out Git branch for workflow_dispatch run configurations. - */ -final class WorkflowCurrentBranchResolver { - - Optional resolve(final Project project) { - return Optional.ofNullable(project) - .map(ProjectUtil::guessProjectDir) - .map(VirtualFile::getPath) - .map(Path::of) - .flatMap(this::resolve); - } - - Optional resolve(final Project project, final VirtualFile file) { - return repositoryRoot(file) - .flatMap(this::resolve) - .or(() -> resolve(project)); - } - - Optional resolve(final Path projectDir) { - return gitDir(projectDir) - .map(dir -> dir.resolve("HEAD")) - .flatMap(WorkflowCurrentBranchResolver::readString) - .flatMap(WorkflowCurrentBranchResolver::branchName); - } - - static Optional branchName(final String head) { - final String prefix = "ref: refs/heads/"; - return Optional.ofNullable(head) - .map(String::trim) - .filter(value -> value.startsWith(prefix)) - .map(value -> value.substring(prefix.length())) - .filter(value -> !value.isBlank()); - } - - private static Optional gitDir(final Path projectDir) { - final Path dotGit = projectDir.resolve(".git"); - if (Files.isDirectory(dotGit)) { - return Optional.of(dotGit); - } - if (!Files.isRegularFile(dotGit)) { - return Optional.empty(); - } - return readString(dotGit) - .map(String::trim) - .filter(value -> value.startsWith("gitdir:")) - .map(value -> value.substring("gitdir:".length()).trim()) - .filter(value -> !value.isBlank()) - .map(Path::of) - .map(path -> path.isAbsolute() ? path : projectDir.resolve(path).normalize()); - } - - private static Optional readString(final Path path) { - try { - return Optional.of(Files.readString(path)); - } catch (final IOException ignored) { - return Optional.empty(); - } - } - - private static Optional repositoryRoot(final VirtualFile file) { - Path current = Optional.ofNullable(file) - .map(VirtualFile::getPath) - .map(Path::of) - .map(Path::getParent) - .orElse(null); - while (current != null) { - if (Files.isDirectory(current.resolve(".git")) || Files.isRegularFile(current.resolve(".git"))) { - return Optional.of(current); - } - current = current.getParent(); - } - return Optional.empty(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputs.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputs.java deleted file mode 100644 index e6108f5..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputs.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -/** - * Lightweight reader for workflow_dispatch inputs used by run configuration defaults. - */ -public final class WorkflowDispatchInputs { - - public List parse(final String yaml) { - final List lines = lines(yaml); - final Optional workflowDispatchIndex = workflowDispatchIndex(lines); - if (workflowDispatchIndex.isEmpty()) { - return List.of(); - } - final int workflowDispatchIndent = lines.get(workflowDispatchIndex.get()).indent(); - final Optional inputsIndex = childIndex(lines, workflowDispatchIndex.get() + 1, workflowDispatchIndent, "inputs"); - if (inputsIndex.isEmpty()) { - return List.of(); - } - final int inputsIndent = lines.get(inputsIndex.get()).indent(); - final List result = new ArrayList<>(); - for (int index = inputsIndex.get() + 1; index < lines.size(); index++) { - final Line line = lines.get(index); - if (line.indent() <= inputsIndent) { - break; - } - if (line.indent() == inputsIndent + 2 && line.keyValue().isPresent()) { - result.add(readInput(lines, index, inputsIndent + 2)); - } - } - return List.copyOf(result); - } - - public boolean hasWorkflowDispatch(final String yaml) { - return workflowDispatchIndex(lines(yaml)).isPresent(); - } - - public String defaultsText(final String yaml) { - final StringBuilder result = new StringBuilder(); - for (final Input input : parse(yaml)) { - result.append(input.name()).append("=").append(input.defaultValue()).append("\n"); - } - return result.toString(); - } - - public static java.util.Map parseKeyValueText(final String text) { - final java.util.LinkedHashMap result = new java.util.LinkedHashMap<>(); - Optional.ofNullable(text).orElse("").lines() - .map(String::trim) - .filter(line -> !line.isBlank()) - .filter(line -> !line.startsWith("#")) - .forEach(line -> { - final int separator = line.indexOf('='); - if (separator > 0) { - result.put(line.substring(0, separator).trim(), line.substring(separator + 1).trim()); - } - }); - return java.util.Map.copyOf(result); - } - - private static Input readInput(final List lines, final int inputIndex, final int inputIndent) { - final String name = lines.get(inputIndex).keyValue().orElse(""); - String type = "string"; - String required = "false"; - String defaultValue = ""; - String description = ""; - final List options = new ArrayList<>(); - for (int index = inputIndex + 1; index < lines.size(); index++) { - final Line line = lines.get(index); - if (line.indent() <= inputIndent) { - break; - } - if (line.indent() == inputIndent + 2) { - if ("type".equals(line.keyValue().orElse(""))) { - type = line.value(); - } else if ("required".equals(line.keyValue().orElse(""))) { - required = line.value(); - } else if ("default".equals(line.keyValue().orElse(""))) { - defaultValue = line.value(); - } else if ("description".equals(line.keyValue().orElse(""))) { - description = line.value(); - } else if ("options".equals(line.keyValue().orElse(""))) { - options.addAll(readOptions(lines, index, inputIndent + 2)); - } - } - } - return new Input(name, type, Boolean.parseBoolean(required), defaultValue, description, List.copyOf(options)); - } - - private static List readOptions(final List lines, final int optionsIndex, final int optionsIndent) { - final List result = new ArrayList<>(inlineOptions(lines.get(optionsIndex).value())); - for (int index = optionsIndex + 1; index < lines.size(); index++) { - final Line line = lines.get(index); - if (line.indent() <= optionsIndent) { - break; - } - if (line.content().startsWith("- ")) { - result.add(stripQuotes(line.content().substring(2).trim())); - } - } - return List.copyOf(result); - } - - private static List inlineOptions(final String value) { - final String trimmed = value == null ? "" : value.trim(); - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { - return List.of(); - } - final String body = trimmed.substring(1, trimmed.length() - 1); - if (body.isBlank()) { - return List.of(); - } - return splitInlineList(body).stream() - .filter(option -> !option.isBlank()) - .map(WorkflowDispatchInputs::stripQuotes) - .toList(); - } - - private static List splitInlineList(final String body) { - final List result = new ArrayList<>(); - final StringBuilder current = new StringBuilder(); - char quote = 0; - for (int index = 0; index < body.length(); index++) { - final char character = body.charAt(index); - if (quote != 0) { - current.append(character); - if (character == quote) { - quote = 0; - } - } else if (character == '\'' || character == '"') { - quote = character; - current.append(character); - } else if (character == ',') { - result.add(current.toString().trim()); - current.setLength(0); - } else { - current.append(character); - } - } - result.add(current.toString().trim()); - return List.copyOf(result); - } - - private static Optional workflowDispatchIndex(final List lines) { - for (int index = 0; index < lines.size(); index++) { - final Line line = lines.get(index); - if ("workflow_dispatch".equals(line.keyValue().orElse("")) || "on".equals(line.keyValue().orElse("")) && "workflow_dispatch".equals(line.value())) { - return Optional.of(index); - } - if (line.content().equals("- workflow_dispatch")) { - return Optional.of(index); - } - } - return Optional.empty(); - } - - private static Optional childIndex(final List lines, final int start, final int parentIndent, final String key) { - for (int index = start; index < lines.size(); index++) { - final Line line = lines.get(index); - if (line.indent() <= parentIndent) { - break; - } - if (key.equals(line.keyValue().orElse(""))) { - return Optional.of(index); - } - } - return Optional.empty(); - } - - private static List lines(final String yaml) { - final List result = new ArrayList<>(); - Optional.ofNullable(yaml).orElse("").lines() - .map(WorkflowDispatchInputs::line) - .filter(line -> !line.content().isBlank()) - .filter(line -> !line.content().startsWith("#")) - .forEach(result::add); - return result; - } - - private static Line line(final String raw) { - int indent = 0; - while (indent < raw.length() && raw.charAt(indent) == ' ') { - indent++; - } - final String content = raw.substring(indent).trim(); - final int separator = content.indexOf(':'); - if (separator < 0) { - return new Line(indent, content, "", ""); - } - final String key = content.substring(0, separator).trim(); - final String value = stripQuotes(content.substring(separator + 1).trim()); - return new Line(indent, content, key, value); - } - - private static String stripQuotes(final String value) { - if (value.length() >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) { - return value.substring(1, value.length() - 1); - } - return value; - } - - public record Input(String name, String type, boolean required, String defaultValue, String description, List options) { - public Input( - final String name, - final String type, - final boolean required, - final String defaultValue, - final String description - ) { - this(name, type, required, defaultValue, description, List.of()); - } - - public Input { - options = options == null ? List.of() : List.copyOf(options); - } - } - - private record Line(int indent, String content, String key, String value) { - Optional keyValue() { - return key.isBlank() ? Optional.empty() : Optional.of(key); - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepository.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepository.java deleted file mode 100644 index f21de2d..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -/** - * Resolved GitHub repository endpoint for workflow execution. - * - * @param webUrl browser base URL - * @param apiUrl REST API base URL - * @param owner repository owner - * @param repo repository name - */ -public record WorkflowRepository(String webUrl, String apiUrl, String owner, String repo) { -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolver.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolver.java deleted file mode 100644 index daa2bf0..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolver.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectUtil; -import com.intellij.openapi.vfs.VirtualFile; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Resolves the GitHub repository for the current project from `.git/config`. - */ -public final class WorkflowRepositoryResolver { - - private static final Pattern HTTPS_REMOTE = Pattern.compile("https?://([^/]+)/([^/]+)/([^/]+?)(?:[.]git)?/?$"); - private static final Pattern SSH_REMOTE = Pattern.compile("(?:git@|ssh://git@)([^:/]+)[:/]([^/]+)/([^/]+?)(?:[.]git)?/?$"); - - public Optional resolve(final Project project) { - return Optional.ofNullable(project) - .map(ProjectUtil::guessProjectDir) - .map(VirtualFile::getPath) - .map(Path::of) - .flatMap(this::resolve); - } - - public Optional resolve(final Project project, final VirtualFile file) { - return repositoryRoot(file) - .flatMap(this::resolve) - .or(() -> resolve(project)); - } - - Optional resolve(final Path projectDir) { - return readGitConfig(projectDir) - .flatMap(WorkflowRepositoryResolver::firstOriginUrl) - .flatMap(WorkflowRepositoryResolver::fromRemoteUrl); - } - - static Optional fromRemoteUrl(final String remoteUrl) { - return match(HTTPS_REMOTE, remoteUrl).or(() -> match(SSH_REMOTE, remoteUrl)); - } - - private static Optional match(final Pattern pattern, final String remoteUrl) { - final Matcher matcher = pattern.matcher(Optional.ofNullable(remoteUrl).orElse("").trim()); - if (!matcher.matches()) { - return Optional.empty(); - } - final String host = matcher.group(1); - final String owner = matcher.group(2); - final String repo = matcher.group(3); - final String webUrl = "https://" + host; - final String apiUrl = "github.com".equalsIgnoreCase(host) - ? "https://api.github.com" - : webUrl + "/api/v3"; - return Optional.of(new WorkflowRepository(webUrl, apiUrl, owner, repo)); - } - - private static Optional readGitConfig(final Path projectDir) { - final Path config = projectDir.resolve(".git").resolve("config"); - if (!Files.isRegularFile(config)) { - return Optional.empty(); - } - try { - return Optional.of(Files.readString(config)); - } catch (final IOException ignored) { - return Optional.empty(); - } - } - - private static Optional repositoryRoot(final VirtualFile file) { - Path current = Optional.ofNullable(file) - .map(VirtualFile::getPath) - .map(Path::of) - .map(Path::getParent) - .orElse(null); - while (current != null) { - if (Files.isRegularFile(current.resolve(".git").resolve("config"))) { - return Optional.of(current); - } - current = current.getParent(); - } - return Optional.empty(); - } - - private static Optional firstOriginUrl(final String config) { - boolean inOrigin = false; - for (final String line : config.split("\\R")) { - final String trimmed = line.trim(); - if (trimmed.startsWith("[remote ")) { - inOrigin = trimmed.equals("[remote \"origin\"]"); - continue; - } - if (inOrigin && trimmed.startsWith("url =")) { - return Optional.of(trimmed.substring("url =".length()).trim()).filter(value -> !value.isBlank()); - } - } - return Optional.empty(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClient.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClient.java deleted file mode 100644 index 076d004..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClient.java +++ /dev/null @@ -1,658 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.intellij.openapi.project.Project; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.StringJoiner; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -/** - * Small GitHub Actions REST client for workflow dispatch, status polling, cancellation, and logs. - */ -public final class WorkflowRunClient { - - private static final String API_VERSION = "2026-03-10"; - private static final Duration TIMEOUT = Duration.ofSeconds(20); - - private final HttpTransport transport; - private final AuthorizationProvider authorizationProvider; - private final ConcurrentMap successfulAuthorizations = new ConcurrentHashMap<>(); - - public WorkflowRunClient() { - this((Project) null); - } - - public WorkflowRunClient(final Project project) { - this(new JdkHttpTransport(HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build()), request -> GitHubRequestAuthorizations.forApiUrl(request.apiUrl(), request.tokenEnvVar(), project)); - } - - WorkflowRunClient(final HttpTransport transport) { - this(transport, request -> GitHubRequestAuthorizations.forApiUrl(request.apiUrl(), request.tokenEnvVar(), null)); - } - - WorkflowRunClient(final HttpTransport transport, final AuthorizationProvider authorizationProvider) { - this.transport = transport; - this.authorizationProvider = authorizationProvider; - } - - public DispatchResult dispatch(final WorkflowRunRequest request) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "POST", - workflowUrl(request) + "/dispatches", - dispatchBody(request), - "GitHub workflow dispatch" - ); - final JsonObject json = parseObject(response.body()); - return new DispatchResult( - longValue(json, "workflow_run_id").orElse(-1L), - stringValue(json, "run_url").orElse(""), - stringValue(json, "html_url").orElse("") - ); - } - - public RunStatus status(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "GET", - runUrl(request, runId), - "", - "GitHub workflow status" - ); - final JsonObject json = parseObject(response.body()); - return new RunStatus( - longValue(json, "id").orElse(runId), - stringValue(json, "status").orElse("unknown"), - stringValue(json, "conclusion").orElse(""), - stringValue(json, "html_url").orElse("") - ); - } - - public CancelResult cancel(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "POST", - runUrl(request, runId) + "/cancel", - "", - "GitHub workflow cancel" - ); - return new CancelResult(response.statusCode(), response.statusCode() / 100 == 2); - } - - /** - * Requests GitHub to re-run a completed workflow run. - * - * @param request workflow repository and authorization context - * @param runId GitHub Actions run id - * @param failedOnly whether only failed jobs should be re-run - * @return HTTP status and whether GitHub accepted the re-run - * @throws IOException when GitHub rejects the request or the network call fails - * @throws InterruptedException when the IDE cancels the remote call - */ - public RerunResult rerun( - final WorkflowRunRequest request, - final long runId, - final boolean failedOnly - ) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "POST", - runUrl(request, runId) + (failedOnly ? "/rerun-failed-jobs" : "/rerun"), - "", - failedOnly ? "GitHub workflow failed jobs rerun" : "GitHub workflow rerun" - ); - return new RerunResult(response.statusCode(), response.statusCode() / 100 == 2); - } - - /** - * Deletes one completed workflow run from the remote repository. - * - * @param request workflow repository and authorization context - * @param runId GitHub Actions run id - * @return HTTP status and whether GitHub accepted the deletion - * @throws IOException when GitHub rejects the request or the network call fails - * @throws InterruptedException when the IDE cancels the remote call - */ - public DeleteResult delete(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "DELETE", - runUrl(request, runId), - "", - "GitHub workflow run delete" - ); - return new DeleteResult(response.statusCode(), response.statusCode() / 100 == 2); - } - - public Optional latestRun(final WorkflowRunRequest request) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "GET", - workflowUrl(request) + "/runs?branch=" + encode(request.ref()) + "&event=workflow_dispatch&per_page=1", - "", - "GitHub workflow run discovery" - ); - final JsonObject json = parseObject(response.body()); - return Optional.ofNullable(json.get("workflow_runs")) - .filter(JsonElement::isJsonArray) - .map(JsonElement::getAsJsonArray) - .filter(runs -> !runs.isEmpty()) - .map(runs -> runs.get(0)) - .filter(JsonElement::isJsonObject) - .map(JsonElement::getAsJsonObject) - .map(run -> new RunStatus( - longValue(run, "id").orElse(-1L), - stringValue(run, "status").orElse("unknown"), - stringValue(run, "conclusion").orElse(""), - stringValue(run, "html_url").orElse("") - )) - .filter(run -> run.runId() >= 0); - } - - public String logs(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final StringBuilder result = new StringBuilder(); - for (final JobStatus job : jobs(request, runId)) { - result.append("== ").append(job.name()).append(" [").append(job.status()).append(resultSuffix(job.conclusion())).append("]\n"); - final String logs = jobLogs(request, job.id()); - if (hasText(logs)) { - result.append(logs.stripTrailing()).append("\n"); - } - } - return result.toString(); - } - - /** - * Lists the artifacts produced by one workflow run. - * - * @param request workflow repository and authorization context - * @param runId GitHub Actions run id - * @return immutable list of artifacts known to GitHub for the run - * @throws IOException when GitHub rejects the request or the network call fails - * @throws InterruptedException when the IDE cancels the remote call - */ - public List artifacts(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "GET", - runUrl(request, runId) + "/artifacts?per_page=100", - "", - "GitHub workflow artifacts" - ); - final JsonObject json = parseObject(response.body()); - final List result = new ArrayList<>(); - Optional.ofNullable(json.get("artifacts")) - .filter(JsonElement::isJsonArray) - .map(JsonElement::getAsJsonArray) - .ifPresent(artifacts -> artifacts.forEach(artifact -> { - if (artifact.isJsonObject()) { - final JsonObject object = artifact.getAsJsonObject(); - result.add(new ArtifactStatus( - longValue(object, "id").orElse(-1L), - stringValue(object, "name").orElse("artifact"), - longValue(object, "size_in_bytes").orElse(0L), - booleanValue(object, "expired").orElse(false), - stringValue(object, "archive_download_url").orElse("") - )); - } - })); - return result.stream().filter(artifact -> artifact.id() >= 0).toList(); - } - - /** - * Downloads one workflow artifact archive as bytes. - * - * @param request workflow repository and authorization context - * @param artifactId GitHub Actions artifact id - * @return zip archive bytes - * @throws IOException when GitHub rejects the request or the network call fails - * @throws InterruptedException when the IDE cancels the remote call - */ - public byte[] artifactZip(final WorkflowRunRequest request, final long artifactId) throws IOException, InterruptedException { - final HttpResponse response = sendBytes( - request, - "GET", - request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/artifacts/" + artifactId + "/zip", - "", - "GitHub workflow artifact download" - ); - return response.body(); - } - - public List jobs(final WorkflowRunRequest request, final long runId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "GET", - runUrl(request, runId) + "/jobs", - "", - "GitHub workflow jobs" - ); - final JsonObject json = parseObject(response.body()); - final List result = new ArrayList<>(); - Optional.ofNullable(json.get("jobs")) - .filter(JsonElement::isJsonArray) - .map(JsonElement::getAsJsonArray) - .ifPresent(jobs -> jobs.forEach(job -> { - if (job.isJsonObject()) { - final JsonObject object = job.getAsJsonObject(); - result.add(new JobStatus( - longValue(object, "id").orElse(-1L), - stringValue(object, "name").orElse("job"), - stringValue(object, "status").orElse("unknown"), - stringValue(object, "conclusion").orElse(""), - stringValue(object, "html_url").orElse("") - )); - } - })); - return result.stream().filter(job -> job.id() >= 0).toList(); - } - - public String jobLogs(final WorkflowRunRequest request, final long jobId) throws IOException, InterruptedException { - final HttpResponse response = send( - request, - "GET", - request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/jobs/" + jobId + "/logs", - "", - "GitHub workflow job logs" - ); - return response.body(); - } - - private HttpResponse send( - final WorkflowRunRequest workflow, - final String method, - final String url, - final String body, - final String operation - ) throws IOException, InterruptedException { - WorkflowRunHttpException lastFailure = null; - boolean authenticatedRateLimitFailure = false; - final String authorizationCacheKey = authorizationCacheKey(workflow); - for (final GitHubRequestAuthorizations.Authorization authorization : authorizations(workflow, authorizationCacheKey)) { - if (!authorization.authenticated() && authenticatedRateLimitFailure) { - break; - } - final HttpResponse response = transport.send(request(workflow, method, url, body, authorization)); - if (response.statusCode() / 100 == 2) { - if (authorization.authenticated()) { - successfulAuthorizations.put(authorizationCacheKey, authorization); - } - return response; - } - lastFailure = failure(operation, response); - if (authorization.authenticated() && rateLimitExceeded(response)) { - authenticatedRateLimitFailure = true; - } - if (!shouldTryNextAuthorization(response.statusCode())) { - throw lastFailure; - } - } - throw lastFailure == null - ? new IOException(operation + " failed: no authorization candidates were available.") - : lastFailure; - } - - private HttpResponse sendBytes( - final WorkflowRunRequest workflow, - final String method, - final String url, - final String body, - final String operation - ) throws IOException, InterruptedException { - WorkflowRunHttpException lastFailure = null; - boolean authenticatedRateLimitFailure = false; - final String authorizationCacheKey = authorizationCacheKey(workflow); - for (final GitHubRequestAuthorizations.Authorization authorization : authorizations(workflow, authorizationCacheKey)) { - if (!authorization.authenticated() && authenticatedRateLimitFailure) { - break; - } - final HttpResponse response = transport.sendBytes(request(workflow, method, url, body, authorization)); - if (response.statusCode() / 100 == 2) { - if (authorization.authenticated()) { - successfulAuthorizations.put(authorizationCacheKey, authorization); - } - return response; - } - lastFailure = failureBytes(operation, response); - if (authorization.authenticated() && rateLimitExceeded( - response.statusCode(), - response.headers(), - new String(Optional.ofNullable(response.body()).orElseGet(() -> new byte[0]), StandardCharsets.UTF_8) - )) { - authenticatedRateLimitFailure = true; - } - if (!shouldTryNextAuthorization(response.statusCode())) { - throw lastFailure; - } - } - throw lastFailure == null - ? new IOException(operation + " failed: no authorization candidates were available.") - : lastFailure; - } - - private List authorizations( - final WorkflowRunRequest workflow, - final String authorizationCacheKey - ) { - final List result = new ArrayList<>(); - Optional.ofNullable(successfulAuthorizations.get(authorizationCacheKey)).ifPresent(result::add); - final List authorizations = authorizationProvider.authorizations(workflow); - if (authorizations == null || authorizations.isEmpty()) { - result.add(GitHubRequestAuthorizations.Authorization.anonymous()); - } else { - result.addAll(authorizations); - } - return result.stream() - .filter(WorkflowRunClient::knownAuthorization) - .distinct() - .toList(); - } - - private static boolean knownAuthorization(final GitHubRequestAuthorizations.Authorization authorization) { - return authorization != null; - } - - private static HttpRequest request( - final WorkflowRunRequest workflow, - final String method, - final String url, - final String body, - final GitHubRequestAuthorizations.Authorization authorization - ) { - final HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url)) - .timeout(TIMEOUT) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", API_VERSION) - .header("User-Agent", "GitHub-Workflow-Plugin"); - if (authorization.authenticated()) { - builder.header("Authorization", authorization.authorizationHeader()); - } - if ("POST".equals(method)) { - builder.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); - } else if ("DELETE".equals(method)) { - builder.DELETE(); - } else { - builder.GET(); - } - return builder.build(); - } - - private static WorkflowRunHttpException failure(final String operation, final HttpResponse response) { - final boolean accountActionRecommended = needsAccountAction(response); - final String hint = accountActionRecommended - ? "\nAdd or refresh GitHub accounts in " + GitHubRequestAuthorizations.settingsHint() + "." - : ""; - final String summary = responseSummary(response); - return new WorkflowRunHttpException( - operation + " failed with HTTP " + response.statusCode() + (summary.isEmpty() ? "" : ": " + summary) + hint, - response.statusCode(), - response.body(), - accountActionRecommended - ); - } - - private static WorkflowRunHttpException failureBytes(final String operation, final HttpResponse response) { - final String body = new String(Optional.ofNullable(response.body()).orElseGet(() -> new byte[0]), StandardCharsets.UTF_8); - final boolean accountActionRecommended = needsAccountAction(response.statusCode(), response.headers(), body); - final String hint = accountActionRecommended - ? "\nAdd or refresh GitHub accounts in " + GitHubRequestAuthorizations.settingsHint() + "." - : ""; - final String summary = responseSummary(response.statusCode(), response.headers(), body); - return new WorkflowRunHttpException( - operation + " failed with HTTP " + response.statusCode() + (summary.isEmpty() ? "" : ": " + summary) + hint, - response.statusCode(), - body, - accountActionRecommended - ); - } - - private static String responseSummary(final HttpResponse response) { - return responseSummary(response.statusCode(), response.headers(), response.body()); - } - - private static String responseSummary(final int statusCode, final HttpHeaders headers, final String responseBody) { - final String body = Optional.ofNullable(responseBody).orElse("").strip(); - if (body.isEmpty()) { - return ""; - } - final String contentType = headers - .firstValue("Content-Type") - .orElse("") - .toLowerCase(); - if (contentType.contains("text/html") || body.startsWith(" response) { - return rateLimitExceeded(response.statusCode(), response.headers(), response.body()); - } - - private static boolean rateLimitExceeded(final int statusCode, final HttpHeaders headers, final String body) { - if (statusCode != 403 && statusCode != 429) { - return false; - } - if (headers - .firstValue("x-ratelimit-remaining") - .map(String::trim) - .filter("0"::equals) - .isPresent()) { - return true; - } - return Optional.ofNullable(body) - .map(value -> value.toLowerCase(Locale.ROOT)) - .filter(value -> value.contains("rate limit")) - .isPresent(); - } - - private static boolean needsAccountAction(final HttpResponse response) { - return needsAccountAction(response.statusCode(), response.headers(), response.body()); - } - - private static boolean needsAccountAction(final int statusCode, final HttpHeaders headers, final String body) { - if (statusCode == 401 || statusCode == 429) { - return true; - } - if (statusCode != 403) { - return false; - } - return !mustHaveAdminRights(body) || rateLimitExceeded(statusCode, headers, body); - } - - private static boolean mustHaveAdminRights(final HttpResponse response) { - return mustHaveAdminRights(response.body()); - } - - private static boolean mustHaveAdminRights(final String body) { - return Optional.ofNullable(body) - .map(value -> value.toLowerCase(Locale.ROOT)) - .filter(value -> value.contains("must have admin rights")) - .isPresent(); - } - - private static String workflowUrl(final WorkflowRunRequest request) { - return request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/workflows/" + encode(workflowId(request.workflowPath())); - } - - private static String authorizationCacheKey(final WorkflowRunRequest request) { - return Optional.ofNullable(request.apiUrl()).orElse("") + "|" + Optional.ofNullable(request.tokenEnvVar()).orElse(""); - } - - private static String runUrl(final WorkflowRunRequest request, final long runId) { - return request.apiUrl() + "/repos/" + encode(request.owner()) + "/" + encode(request.repo()) + "/actions/runs/" + runId; - } - - private static String workflowId(final String workflowPath) { - final String normalized = Optional.ofNullable(workflowPath).orElse("").replace('\\', '/'); - final int slash = normalized.lastIndexOf('/'); - return slash < 0 ? normalized : normalized.substring(slash + 1); - } - - private static String dispatchBody(final WorkflowRunRequest request) { - final StringJoiner inputs = new StringJoiner(","); - request.inputs().entrySet().stream() - .filter(entry -> hasText(entry.getKey())) - .limit(25) - .forEach(entry -> inputs.add(quote(entry.getKey()) + ":" + quote(entry.getValue()))); - final String inputsJson = inputs.length() == 0 ? "" : ",\"inputs\":{" + inputs + "}"; - return "{\"ref\":" + quote(request.ref()) + inputsJson + "}"; - } - - private static String resultSuffix(final String conclusion) { - return hasText(conclusion) ? "/" + conclusion : ""; - } - - private static JsonObject parseObject(final String body) { - if (!hasText(body)) { - return new JsonObject(); - } - final JsonElement element = JsonParser.parseString(body); - return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); - } - - private static Optional stringValue(final JsonObject object, final String name) { - return Optional.ofNullable(object.get(name)) - .filter(JsonElement::isJsonPrimitive) - .map(JsonElement::getAsString) - .filter(WorkflowRunClient::hasText); - } - - private static Optional longValue(final JsonObject object, final String name) { - return Optional.ofNullable(object.get(name)) - .filter(JsonElement::isJsonPrimitive) - .map(value -> { - try { - return value.getAsLong(); - } catch (final NumberFormatException ignored) { - return -1L; - } - }) - .filter(value -> value >= 0); - } - - private static Optional booleanValue(final JsonObject object, final String name) { - return Optional.ofNullable(object.get(name)) - .filter(JsonElement::isJsonPrimitive) - .map(JsonElement::getAsBoolean); - } - - private static String encode(final String value) { - return URLEncoder.encode(Optional.ofNullable(value).orElse(""), StandardCharsets.UTF_8).replace("+", "%20"); - } - - private static String quote(final String value) { - return "\"" + Optional.ofNullable(value).orElse("") - .replace("\\", "\\\\") - .replace("\"", "\\\"") - .replace("\n", "\\n") - .replace("\r", "\\r") + "\""; - } - - private static boolean hasText(final String value) { - return value != null && !value.isBlank(); - } - - interface HttpTransport { - HttpResponse send(HttpRequest request) throws IOException, InterruptedException; - - default HttpResponse sendBytes(final HttpRequest request) throws IOException, InterruptedException { - throw new IOException("Binary transport is not available."); - } - } - - interface AuthorizationProvider { - List authorizations(WorkflowRunRequest request); - } - - private record JdkHttpTransport(HttpClient client) implements HttpTransport { - @Override - public HttpResponse send(final HttpRequest request) throws IOException, InterruptedException { - return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - } - - @Override - public HttpResponse sendBytes(final HttpRequest request) throws IOException, InterruptedException { - return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); - } - } - - public record DispatchResult(long runId, String runUrl, String htmlUrl) { - } - - public record RunStatus(long runId, String status, String conclusion, String htmlUrl) { - public boolean completed() { - return "completed".equals(status); - } - } - - public record CancelResult(int statusCode, boolean accepted) { - } - - public record RerunResult(int statusCode, boolean accepted) { - } - - public record DeleteResult(int statusCode, boolean accepted) { - } - - public record JobStatus(long id, String name, String status, String conclusion, String htmlUrl) { - } - - public record ArtifactStatus(long id, String name, long sizeInBytes, boolean expired, String archiveDownloadUrl) { - } - - public static final class WorkflowRunHttpException extends IOException { - - private final int statusCode; - private final String body; - private final boolean accountActionRecommended; - - public WorkflowRunHttpException( - final String message, - final int statusCode, - final String body, - final boolean accountActionRecommended - ) { - super(message); - this.statusCode = statusCode; - this.body = body; - this.accountActionRecommended = accountActionRecommended; - } - - public int statusCode() { - return statusCode; - } - - public String body() { - return body; - } - - public boolean accountActionRecommended() { - return accountActionRecommended; - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfiguration.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfiguration.java deleted file mode 100644 index a853563..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfiguration.java +++ /dev/null @@ -1,178 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.execution.ExecutionException; -import com.intellij.execution.Executor; -import com.intellij.execution.configurations.CommandLineState; -import com.intellij.execution.configurations.ConfigurationFactory; -import com.intellij.execution.configurations.RunConfiguration; -import com.intellij.execution.configurations.RunConfigurationBase; -import com.intellij.execution.configurations.RunProfileState; -import com.intellij.execution.configurations.RuntimeConfigurationError; -import com.intellij.execution.configurations.RuntimeConfigurationException; -import com.intellij.execution.process.ProcessHandler; -import com.intellij.execution.runners.ExecutionEnvironment; -import com.intellij.openapi.options.SettingsEditor; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.InvalidDataException; -import org.jdom.Element; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -/** - * Run configuration that dispatches a workflow_dispatch event and follows the resulting run. - */ -public final class WorkflowRunConfiguration extends RunConfigurationBase { - - private String apiUrl = "https://api.github.com"; - private String owner = ""; - private String repo = ""; - private String workflowPath = ""; - private String ref = "main"; - private String tokenEnvVar = ""; - private String inputsText = ""; - - WorkflowRunConfiguration(final Project project, final ConfigurationFactory factory, final String name) { - super(project, factory, name); - } - - @Override - public @NotNull SettingsEditor getConfigurationEditor() { - return new WorkflowRunSettingsEditor(); - } - - @Override - public @Nullable RunProfileState getState(@NotNull final Executor executor, @NotNull final ExecutionEnvironment environment) { - return new CommandLineState(environment) { - @Override - protected @NotNull ProcessHandler startProcess() throws ExecutionException { - return new WorkflowRunProcessHandler(getProject(), toRequest(), new WorkflowRunClient(getProject()), environment.getExecutor()); - } - }; - } - - @Override - public void checkConfiguration() throws RuntimeConfigurationException { - if (isBlank(apiUrl)) { - throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.apiUrl")); - } - if (isBlank(owner) || isBlank(repo)) { - throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.repository")); - } - if (isBlank(workflowPath)) { - throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.workflow")); - } - if (isBlank(ref)) { - throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.ref")); - } - if (WorkflowDispatchInputs.parseKeyValueText(inputsText).size() > 25) { - throw new RuntimeConfigurationError(GitHubWorkflowBundle.message("workflow.run.error.inputs")); - } - } - - @Override - public void readExternal(@NotNull final Element element) throws InvalidDataException { - super.readExternal(element); - apiUrl = value(element, "apiUrl", apiUrl); - owner = value(element, "owner", owner); - repo = value(element, "repo", repo); - workflowPath = value(element, "workflowPath", workflowPath); - ref = value(element, "ref", ref); - tokenEnvVar = value(element, "tokenEnvVar", tokenEnvVar); - inputsText = value(element, "inputsText", inputsText); - } - - @Override - public void writeExternal(@NotNull final Element element) { - super.writeExternal(element); - element.setAttribute("apiUrl", apiUrl); - element.setAttribute("owner", owner); - element.setAttribute("repo", repo); - element.setAttribute("workflowPath", workflowPath); - element.setAttribute("ref", ref); - element.setAttribute("tokenEnvVar", tokenEnvVar); - element.setAttribute("inputsText", inputsText); - } - - WorkflowRunRequest toRequest() { - final Map inputs = WorkflowDispatchInputs.parseKeyValueText(inputsText); - return new WorkflowRunRequest(apiUrl, owner, repo, workflowPath, ref, inputs, tokenEnvVar); - } - - public String apiUrl() { - return apiUrl; - } - - public WorkflowRunConfiguration apiUrl(final String apiUrl) { - this.apiUrl = clean(apiUrl); - return this; - } - - public String owner() { - return owner; - } - - public WorkflowRunConfiguration owner(final String owner) { - this.owner = clean(owner); - return this; - } - - public String repo() { - return repo; - } - - public WorkflowRunConfiguration repo(final String repo) { - this.repo = clean(repo); - return this; - } - - public String workflowPath() { - return workflowPath; - } - - public WorkflowRunConfiguration workflowPath(final String workflowPath) { - this.workflowPath = clean(workflowPath); - return this; - } - - public String ref() { - return ref; - } - - public WorkflowRunConfiguration ref(final String ref) { - this.ref = clean(ref); - return this; - } - - public String tokenEnvVar() { - return tokenEnvVar; - } - - public WorkflowRunConfiguration tokenEnvVar(final String tokenEnvVar) { - this.tokenEnvVar = clean(tokenEnvVar); - return this; - } - - public String inputsText() { - return inputsText; - } - - public WorkflowRunConfiguration inputsText(final String inputsText) { - this.inputsText = inputsText == null ? "" : inputsText; - return this; - } - - private static String value(final Element element, final String name, final String fallback) { - final String value = element.getAttributeValue(name); - return value == null ? fallback : value; - } - - private static String clean(final String value) { - return value == null ? "" : value.trim(); - } - - private static boolean isBlank(final String value) { - return value == null || value.isBlank(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationProducer.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationProducer.java deleted file mode 100644 index da107b1..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationProducer.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.intellij.execution.actions.ConfigurationContext; -import com.intellij.execution.actions.LazyRunConfigurationProducer; -import com.intellij.execution.configurations.ConfigurationFactory; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Ref; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import org.jetbrains.annotations.NotNull; - -import java.nio.file.Path; -import java.util.Optional; - -/** - * Creates GitHub Workflow run configurations from workflow YAML files. - */ -public final class WorkflowRunConfigurationProducer extends LazyRunConfigurationProducer { - - private static final WorkflowDispatchInputs DISPATCH_INPUTS = new WorkflowDispatchInputs(); - - @Override - public @NotNull ConfigurationFactory getConfigurationFactory() { - return WorkflowRunConfigurationType.getInstance().factory(); - } - - @Override - protected boolean setupConfigurationFromContext( - @NotNull final WorkflowRunConfiguration configuration, - @NotNull final ConfigurationContext context, - @NotNull final Ref sourceElement - ) { - final PsiFile file = workflowFile(context.getPsiLocation()).orElse(null); - if (file == null) { - return false; - } - final Project project = context.getProject(); - final WorkflowRepository repository = new WorkflowRepositoryResolver().resolve(project, file.getVirtualFile()).orElse(null); - if (repository == null) { - return false; - } - final String path = workflowPath(project, file.getVirtualFile()).orElse(file.getName()); - configuration.setName(GitHubWorkflowBundle.message("workflow.run.configuration.name", file.getName())); - configuration.apiUrl(repository.apiUrl()) - .owner(repository.owner()) - .repo(repository.repo()) - .workflowPath(path) - .ref(new WorkflowCurrentBranchResolver().resolve(project, file.getVirtualFile()).orElse("main")) - .tokenEnvVar("") - .inputsText(DISPATCH_INPUTS.defaultsText(file.getText())); - sourceElement.set(file); - return true; - } - - @Override - public boolean isConfigurationFromContext( - @NotNull final WorkflowRunConfiguration configuration, - @NotNull final ConfigurationContext context - ) { - return workflowFile(context.getPsiLocation()) - .flatMap(file -> workflowPath(context.getProject(), file.getVirtualFile())) - .filter(path -> path.equals(configuration.workflowPath())) - .filter(path -> new WorkflowCurrentBranchResolver().resolve(context.getProject()) - .map(branch -> branch.equals(configuration.ref())) - .orElse(true)) - .isPresent(); - } - - private static Optional workflowFile(final PsiElement element) { - return Optional.ofNullable(element) - .map(PsiElement::getContainingFile) - .filter(file -> Optional.ofNullable(file.getVirtualFile()) - .flatMap(PsiElementHelper::toPath) - .filter(GitHubWorkflowHelper::isWorkflowPath) - .isPresent()); - } - - static Optional workflowPath(final Project project, final VirtualFile file) { - return Optional.ofNullable(project) - .flatMap(p -> Optional.ofNullable(com.intellij.openapi.project.ProjectUtil.guessProjectDir(p))) - .map(VirtualFile::getPath) - .map(Path::of) - .flatMap(root -> Optional.ofNullable(file) - .map(VirtualFile::getPath) - .map(Path::of) - .map(root::relativize) - .map(Path::toString) - .map(path -> path.replace('\\', '/'))); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationType.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationType.java deleted file mode 100644 index d67e3cf..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConfigurationType.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.execution.configurations.ConfigurationFactory; -import com.intellij.execution.configurations.ConfigurationType; -import com.intellij.execution.configurations.ConfigurationTypeUtil; -import com.intellij.execution.configurations.RunConfiguration; -import com.intellij.icons.AllIcons; -import com.intellij.openapi.project.Project; -import org.jetbrains.annotations.NotNull; - -import javax.swing.Icon; - -/** - * Run configuration type used to dispatch GitHub Actions workflows from the IDE. - */ -public final class WorkflowRunConfigurationType implements ConfigurationType { - - public static final String ID = "GitHubWorkflow.RunConfiguration"; - - private final ConfigurationFactory factory = new WorkflowRunConfigurationFactory(this); - - public static WorkflowRunConfigurationType getInstance() { - return ConfigurationTypeUtil.findConfigurationType(WorkflowRunConfigurationType.class); - } - - @Override - public String getDisplayName() { - return GitHubWorkflowBundle.message("workflow.run.configuration.display"); - } - - @Override - public String getConfigurationTypeDescription() { - return GitHubWorkflowBundle.message("workflow.run.configuration.description"); - } - - @Override - public Icon getIcon() { - return AllIcons.Actions.Execute; - } - - @Override - public @NotNull String getId() { - return ID; - } - - @Override - public ConfigurationFactory[] getConfigurationFactories() { - return new ConfigurationFactory[]{factory}; - } - - public ConfigurationFactory factory() { - return factory; - } - - private static final class WorkflowRunConfigurationFactory extends ConfigurationFactory { - private WorkflowRunConfigurationFactory(final ConfigurationType type) { - super(type); - } - - @Override - public @NotNull String getId() { - return ID + ".Factory"; - } - - @Override - public RunConfiguration createTemplateConfiguration(@NotNull final Project project) { - return new WorkflowRunConfiguration(project, this, GitHubWorkflowBundle.message("workflow.run.configuration.display")); - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunDownloads.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunDownloads.java deleted file mode 100644 index 72e12c5..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunDownloads.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.ide.actions.RevealFileAction; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.application.PathManager; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Locale; -import java.util.Optional; - -/** - * Stores workflow run downloads under the IDE system directory and reveals them to the user. - */ -final class WorkflowRunDownloads { - - private WorkflowRunDownloads() { - } - - static Path writeJobLog( - final WorkflowRunRequest request, - final long runId, - final long jobId, - final String jobName, - final String log - ) throws IOException { - final Path file = runDirectory(request, runId).resolve(safeName(jobName) + "-" + jobId + ".log"); - Files.writeString(file, Optional.ofNullable(log).orElse(""), StandardCharsets.UTF_8); - return file; - } - - static Path writeArtifact( - final WorkflowRunRequest request, - final long runId, - final WorkflowRunClient.ArtifactStatus artifact, - final byte[] zip - ) throws IOException { - final Path file = runDirectory(request, runId).resolve(safeName(artifact.name()) + "-" + artifact.id() + ".zip"); - Files.write(file, Optional.ofNullable(zip).orElseGet(() -> new byte[0])); - return file; - } - - static void reveal(final Path path) { - if (path == null) { - return; - } - ApplicationManager.getApplication().invokeLater(() -> RevealFileAction.openFile(path.toFile())); - } - - private static Path runDirectory(final WorkflowRunRequest request, final long runId) throws IOException { - final Path directory = Path.of( - PathManager.getSystemPath(), - "github-workflow-plugin", - "downloads", - safeName(request.repositorySlug()), - "run-" + runId - ); - Files.createDirectories(directory); - return directory; - } - - private static String safeName(final String value) { - final String normalized = Optional.ofNullable(value) - .filter(text -> !text.isBlank()) - .orElse("download") - .toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9._-]+", "-") - .replaceAll("^-+|-+$", ""); - return normalized.isBlank() ? "download" : normalized; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunJobConsole.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunJobConsole.java deleted file mode 100644 index bf0d200..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunJobConsole.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -/** - * Receives workflow job status and logs for a Run tool-window workflow view. - */ -interface WorkflowRunJobConsole { - - boolean jobStatus(WorkflowRunClient.JobStatus job, String text); - - boolean jobStdout(WorkflowRunClient.JobStatus job, String text); - - boolean jobStderr(WorkflowRunClient.JobStatus job, String text); - - default boolean jobLog(final WorkflowRunClient.JobStatus job, final String text) { - return jobStdout(job, text); - } - - default void workflowStatus(final String text, final boolean error) { - } - - default void runFinished(final long runId, final String conclusion) { - } - - default void runDeleted(final long runId) { - } - - default void runDeleteFailed(final long runId) { - } - - default void close() { - } - - static WorkflowRunJobConsole none() { - return new WorkflowRunJobConsole() { - @Override - public boolean jobStatus(final WorkflowRunClient.JobStatus job, final String text) { - return false; - } - - @Override - public boolean jobStdout(final WorkflowRunClient.JobStatus job, final String text) { - return false; - } - - @Override - public boolean jobStderr(final WorkflowRunClient.JobStatus job, final String text) { - return false; - } - }; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjector.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjector.java deleted file mode 100644 index 1086766..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjector.java +++ /dev/null @@ -1,221 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.lang.Language; -import com.intellij.lang.injection.MultiHostInjector; -import com.intellij.lang.injection.MultiHostRegistrar; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiElement; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.yaml.psi.YAMLKeyValue; -import org.jetbrains.yaml.psi.YAMLScalar; -import org.jetbrains.yaml.psi.impl.YAMLScalarImpl; - -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUN; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStep; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; - -public final class WorkflowRunLanguageInjector implements MultiHostInjector { - - @Override - public void getLanguagesToInject(@NotNull final MultiHostRegistrar registrar, @NotNull final PsiElement context) { - if (!(context instanceof YAMLScalar scalar) || !isRunScalar(scalar)) { - return; - } - languageForShell(scalar) - .ifPresent(language -> inject(registrar, scalar, language)); - } - - @Override - public @NotNull List> elementsToInjectIn() { - return List.of(YAMLScalar.class); - } - - private static boolean isRunScalar(final YAMLScalar scalar) { - return scalar.getParent() instanceof YAMLKeyValue keyValue && FIELD_RUN.equals(keyValue.getKeyText()); - } - - private static Optional languageForShell(final YAMLScalar scalar) { - return shellFor(scalar) - .map(WorkflowRunLanguageInjector::languageId) - .flatMap(id -> Optional.ofNullable(Language.findLanguageByID(id))); - } - - private static Optional shellFor(final YAMLScalar scalar) { - return getParentStep(scalar) - .flatMap(step -> getText(step, "shell")) - .or(() -> getParentJob(scalar) - .flatMap(job -> getChild(job, "defaults")) - .flatMap(defaults -> getChild(defaults, FIELD_RUN)) - .flatMap(run -> getText(run, "shell"))) - .or(() -> getChild(scalar.getContainingFile(), "defaults") - .flatMap(defaults -> getChild(defaults, FIELD_RUN)) - .flatMap(run -> getText(run, "shell"))) - .or(() -> Optional.of("bash")); - } - - private static String languageId(final String shell) { - final String normalized = shell.toLowerCase(Locale.ROOT).trim(); - if (normalized.contains("pwsh") || normalized.contains("powershell")) { - return "PowerShell"; - } - if (normalized.contains("python")) { - return "Python"; - } - if (normalized.contains("node") || normalized.contains("javascript") || normalized.equals("js")) { - return "JavaScript"; - } - if (normalized.contains("ruby")) { - return "Ruby"; - } - if (normalized.contains("perl")) { - return "Perl"; - } - return "Shell Script"; - } - - private static void inject(final MultiHostRegistrar registrar, final YAMLScalar scalar, final Language language) { - final List ranges = contentRanges(scalar); - if (ranges.isEmpty()) { - return; - } - registrar.startInjecting(language); - ranges.forEach(range -> registrar.addPlace(null, null, scalar, range)); - registrar.doneInjecting(); - } - - private static List contentRanges(final YAMLScalar scalar) { - final List ranges = scalar instanceof YAMLScalarImpl scalarImpl - ? scalarImpl.getContentRanges() - : fallbackContentRanges(scalar); - final List withoutExpressions = ranges.stream() - .flatMap(range -> excludeWorkflowExpressions(scalar.getText(), range).stream()) - .toList(); - return subtractRanges(withoutExpressions, hereDocBodyRanges(scalar.getText(), new TextRange(0, scalar.getTextLength()))).stream() - .filter(range -> range.getStartOffset() < range.getEndOffset()) - .toList(); - } - - private static List fallbackContentRanges(final YAMLScalar scalar) { - final int length = scalar.getTextLength(); - return length == 0 ? List.of() : List.of(new TextRange(0, length)); - } - - private static List excludeWorkflowExpressions(final String text, final TextRange range) { - final java.util.ArrayList result = new java.util.ArrayList<>(); - int start = range.getStartOffset(); - while (start < range.getEndOffset()) { - final int expressionStart = text.indexOf("${{", start); - if (expressionStart < 0 || expressionStart >= range.getEndOffset()) { - result.add(new TextRange(start, range.getEndOffset())); - break; - } - if (start < expressionStart) { - result.add(new TextRange(start, expressionStart)); - } - final int expressionEnd = text.indexOf("}}", expressionStart + 3); - start = expressionEnd < 0 ? range.getEndOffset() : Math.min(expressionEnd + 2, range.getEndOffset()); - } - return result; - } - - private static List hereDocBodyRanges(final String text, final TextRange range) { - final java.util.ArrayList result = new java.util.ArrayList<>(); - String delimiter = ""; - int bodyStart = -1; - int lineStart = range.getStartOffset(); - while (lineStart < range.getEndOffset()) { - final int newline = text.indexOf('\n', lineStart); - final int lineEnd = newline < 0 ? range.getEndOffset() : Math.min(newline, range.getEndOffset()); - final String line = text.substring(lineStart, lineEnd); - if (delimiter.isBlank()) { - final Optional nextDelimiter = hereDocDelimiter(line); - if (nextDelimiter.isPresent()) { - delimiter = nextDelimiter.get(); - bodyStart = Math.min(lineEnd + 1, range.getEndOffset()); - } - } else if (line.trim().equals(delimiter)) { - if (bodyStart >= 0 && bodyStart < lineStart) { - result.add(new TextRange(bodyStart, lineStart)); - } - delimiter = ""; - bodyStart = -1; - } - if (newline < 0 || lineEnd >= range.getEndOffset()) { - break; - } - lineStart = lineEnd + 1; - } - if (!delimiter.isBlank() && bodyStart >= 0 && bodyStart < range.getEndOffset()) { - result.add(new TextRange(bodyStart, range.getEndOffset())); - } - return result; - } - - private static Optional hereDocDelimiter(final String line) { - char quote = 0; - for (int index = 0; index + 1 < line.length(); index++) { - final char current = line.charAt(index); - if (quote != 0) { - if (current == quote) { - quote = 0; - } - continue; - } - if (current == '\'' || current == '"') { - quote = current; - continue; - } - if (current == '<' && line.charAt(index + 1) == '<') { - int delimiterStart = index + 2; - if (delimiterStart < line.length() && line.charAt(delimiterStart) == '-') { - delimiterStart++; - } - while (delimiterStart < line.length() && Character.isWhitespace(line.charAt(delimiterStart))) { - delimiterStart++; - } - int delimiterEnd = delimiterStart; - while (delimiterEnd < line.length() && isDelimiterChar(line.charAt(delimiterEnd))) { - delimiterEnd++; - } - if (delimiterStart < delimiterEnd) { - return Optional.of(line.substring(delimiterStart, delimiterEnd)); - } - } - } - return Optional.empty(); - } - - private static boolean isDelimiterChar(final char character) { - return Character.isLetterOrDigit(character) || character == '_'; - } - - private static List subtractRanges(final List ranges, final List excludedRanges) { - List result = ranges; - for (final TextRange excludedRange : excludedRanges) { - result = result.stream() - .flatMap(range -> subtractRange(range, excludedRange).stream()) - .toList(); - } - return result; - } - - private static List subtractRange(final TextRange range, final TextRange excludedRange) { - if (!range.intersectsStrict(excludedRange)) { - return List.of(range); - } - final java.util.ArrayList result = new java.util.ArrayList<>(); - if (range.getStartOffset() < excludedRange.getStartOffset()) { - result.add(new TextRange(range.getStartOffset(), excludedRange.getStartOffset())); - } - if (excludedRange.getEndOffset() < range.getEndOffset()) { - result.add(new TextRange(excludedRange.getEndOffset(), range.getEndOffset())); - } - return result; - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLineMarkerContributor.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLineMarkerContributor.java deleted file mode 100644 index 863fc00..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLineMarkerContributor.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.execution.lineMarker.RunLineMarkerContributor; -import com.intellij.icons.AllIcons; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiElement; -import com.intellij.psi.impl.source.tree.LeafPsiElement; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.yaml.psi.YAMLKeyValue; - -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Adds the standard Run gutter action to workflow_dispatch entries. - */ -public final class WorkflowRunLineMarkerContributor extends RunLineMarkerContributor { - - private static final RepositoryAvailability DEFAULT_REPOSITORY_AVAILABILITY = - (project, file) -> new WorkflowRepositoryResolver().resolve(project, file).isPresent(); - private static final AtomicReference repositoryAvailability = - new AtomicReference<>(DEFAULT_REPOSITORY_AVAILABILITY); - - @Override - public @Nullable Info getInfo(final PsiElement element) { - if (!(element instanceof LeafPsiElement) || !"workflow_dispatch".equals(element.getText())) { - return null; - } - if (!(element.getParent() instanceof YAMLKeyValue keyValue) || !"workflow_dispatch".equals(keyValue.getKeyText())) { - return null; - } - final Optional workflowPath = Optional.ofNullable(element.getContainingFile()) - .map(file -> file.getVirtualFile()) - .flatMap(file -> WorkflowRunConfigurationProducer.workflowPath(element.getProject(), file) - .or(() -> PsiElementHelper.toPath(file).map(path -> path.getFileName().toString()))); - final boolean workflowFile = Optional.ofNullable(element.getContainingFile()) - .map(file -> file.getVirtualFile()) - .flatMap(PsiElementHelper::toPath) - .filter(GitHubWorkflowHelper::isWorkflowPath) - .isPresent(); - if (!workflowFile || workflowPath.isEmpty()) { - return null; - } - if (WorkflowRunTracker.getInstance(element.getProject()).isRunning(workflowPath.get())) { - return new Info(AllIcons.Actions.Suspend, new AnAction[]{new StopWorkflowRunAction(workflowPath.get())}, item -> GitHubWorkflowBundle.message("workflow.run.gutter.stop")); - } - final boolean repositoryAvailable = Optional.ofNullable(element.getContainingFile()) - .map(file -> file.getVirtualFile()) - .map(file -> repositoryAvailability.get().available(element.getProject(), file)) - .orElse(false); - if (!repositoryAvailable) { - return null; - } - return withExecutorActions(AllIcons.Actions.Execute); - } - - static RepositoryAvailability useRepositoryAvailabilityForTests(final RepositoryAvailability availability) { - return repositoryAvailability.getAndSet(availability == null ? DEFAULT_REPOSITORY_AVAILABILITY : availability); - } - - interface RepositoryAvailability { - boolean available(Project project, VirtualFile file); - } - - private static final class StopWorkflowRunAction extends AnAction { - - private final String workflowPath; - - private StopWorkflowRunAction(final String workflowPath) { - super(GitHubWorkflowBundle.message("workflow.run.gutter.stop.text"), GitHubWorkflowBundle.message("workflow.run.gutter.stop.description"), AllIcons.Actions.Suspend); - this.workflowPath = workflowPath; - } - - @Override - public void actionPerformed(@NotNull final AnActionEvent event) { - Optional.ofNullable(event.getProject()) - .map(WorkflowRunTracker::getInstance) - .ifPresent(tracker -> tracker.stop(workflowPath)); - } - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRenderer.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRenderer.java deleted file mode 100644 index 4ab0e77..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRenderer.java +++ /dev/null @@ -1,209 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Converts raw GitHub Actions log lines into compact IDE console segments. - */ -final class WorkflowRunLogRenderer { - - private static final Pattern TIMESTAMP = Pattern.compile("^\\x{FEFF}?\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?Z\\s+"); - private static final Pattern GITHUB_COMMAND = Pattern.compile("^##\\[([^]]+)](.*)$"); - private static final Pattern WORKFLOW_COMMAND = Pattern.compile("^::([^: ]+)(?: [^:]*)?::(.*)$"); - private static final Pattern ANSI_SGR = Pattern.compile("\\x1B\\[([0-9;]*)m"); - private static final Pattern ANSI_CONTROL = Pattern.compile("\\x1B\\[[0-?]*[ -/]*[@-~]"); - - private int lineNumber = 0; - private boolean printedAny = false; - - static List renderOnce(final String text) { - return new WorkflowRunLogRenderer().render(text); - } - - static String renderPlainOnce(final String text) { - return new WorkflowRunLogRenderer().renderPlain(text); - } - - List render(final String text) { - if (text == null || text.isEmpty()) { - return List.of(); - } - final List result = new ArrayList<>(); - int start = 0; - while (start < text.length()) { - final int next = nextLineEnd(text, start); - appendLine(result, text.substring(start, next)); - start = next; - } - return List.copyOf(result); - } - - String renderPlain(final String text) { - final StringBuilder result = new StringBuilder(); - for (final Segment segment : render(text)) { - result.append(segment.text()); - } - return result.toString(); - } - - private void appendLine(final List result, final String rawLine) { - final LineParts parts = splitLine(rawLine); - final AnsiLine ansiLine = stripAnsi(TIMESTAMP.matcher(parts.text()).replaceFirst("")); - final String line = ansiLine.text(); - final Matcher githubCommand = GITHUB_COMMAND.matcher(line); - if (githubCommand.matches()) { - appendGitHubCommand(result, githubCommand.group(1), githubCommand.group(2), parts.separator()); - return; - } - final Matcher workflowCommand = WORKFLOW_COMMAND.matcher(line); - if (workflowCommand.matches()) { - appendWorkflowCommand(result, workflowCommand.group(1), workflowCommand.group(2), parts.separator()); - return; - } - appendNumbered(result, line, ansiLine.kind() == Kind.NORMAL ? inferredKind(line) : ansiLine.kind(), parts.separator()); - } - - private void appendGitHubCommand(final List result, final String command, final String value, final String separator) { - final String name = commandName(command); - switch (name) { - case "group" -> appendBlockHeader(result, value); - case "endgroup", "/group" -> appendBlockEnd(); - case "command" -> appendNumbered(result, label("workflow.log.command") + " " + value, Kind.SYSTEM, separator); - case "warning" -> appendNumbered(result, label("workflow.log.warning") + " " + value, Kind.WARNING, separator); - case "error" -> appendNumbered(result, label("workflow.log.error") + " " + value, Kind.ERROR, separator); - default -> appendNumbered(result, value.isBlank() ? "[" + name + "]" : value, Kind.SYSTEM, separator); - } - } - - private void appendWorkflowCommand(final List result, final String command, final String value, final String separator) { - final String name = commandName(command); - switch (name) { - case "warning" -> appendNumbered(result, label("workflow.log.warning") + " " + value, Kind.WARNING, separator); - case "error" -> appendNumbered(result, label("workflow.log.error") + " " + value, Kind.ERROR, separator); - case "group" -> appendBlockHeader(result, value); - case "endgroup", "/group" -> appendBlockEnd(); - default -> appendNumbered(result, value, Kind.SYSTEM, separator); - } - } - - private void appendBlockHeader(final List result, final String title) { - final String prefix = printedAny ? "\n" : ""; - result.add(new Segment(prefix + "== " + title.strip() + " ==\n", Kind.SYSTEM)); - lineNumber = 0; - printedAny = true; - } - - private void appendBlockEnd() { - lineNumber = 0; - } - - private void appendNumbered(final List result, final String line, final Kind kind, final String separator) { - printedAny = true; - if (line.isBlank()) { - result.add(new Segment(separator, kind)); - return; - } - lineNumber++; - result.add(new Segment(String.format(Locale.ROOT, "%04d | %s%s", lineNumber, line, separator), kind)); - } - - private static AnsiLine stripAnsi(final String line) { - Kind kind = Kind.NORMAL; - final Matcher matcher = ANSI_SGR.matcher(line); - while (matcher.find()) { - kind = strongest(kind, kindForAnsi(matcher.group(1))); - } - return new AnsiLine(ANSI_CONTROL.matcher(line).replaceAll(""), kind); - } - - private static Kind kindForAnsi(final String value) { - final String[] codes = value.isBlank() ? new String[]{"0"} : value.split(";"); - Kind result = Kind.NORMAL; - for (final String code : codes) { - result = strongest(result, switch (code) { - case "31", "91" -> Kind.ERROR; - case "33", "93" -> Kind.WARNING; - case "34", "35", "36", "90", "94", "95", "96" -> Kind.SYSTEM; - default -> Kind.NORMAL; - }); - } - return result; - } - - private static Kind strongest(final Kind left, final Kind right) { - return weight(right) > weight(left) ? right : left; - } - - private static int weight(final Kind kind) { - return switch (kind) { - case ERROR -> 3; - case WARNING -> 2; - case SYSTEM -> 1; - case NORMAL -> 0; - }; - } - - private static Kind inferredKind(final String line) { - final String normalized = line.stripLeading().toLowerCase(Locale.ROOT); - if (normalized.startsWith("error:") || normalized.startsWith("fatal:")) { - return Kind.ERROR; - } - if (normalized.startsWith("warning:") || normalized.startsWith("npm warn ")) { - return Kind.WARNING; - } - return Kind.NORMAL; - } - - private static String commandName(final String command) { - final int space = command.indexOf(' '); - return (space >= 0 ? command.substring(0, space) : command).toLowerCase(Locale.ROOT); - } - - private static String label(final String key) { - return GitHubWorkflowBundle.message(key); - } - - private static LineParts splitLine(final String line) { - if (line.endsWith("\r\n")) { - return new LineParts(line.substring(0, line.length() - 2), "\r\n"); - } - if (line.endsWith("\n") || line.endsWith("\r")) { - return new LineParts(line.substring(0, line.length() - 1), line.substring(line.length() - 1)); - } - return new LineParts(line, ""); - } - - private static int nextLineEnd(final String text, final int start) { - int index = start; - while (index < text.length() && text.charAt(index) != '\n' && text.charAt(index) != '\r') { - index++; - } - if (index >= text.length()) { - return index; - } - if (text.charAt(index) == '\r' && index + 1 < text.length() && text.charAt(index + 1) == '\n') { - return index + 2; - } - return index + 1; - } - - enum Kind { - NORMAL, - SYSTEM, - WARNING, - ERROR - } - - record Segment(String text, Kind kind) { - } - - private record AnsiLine(String text, Kind kind) { - } - - private record LineParts(String text, String separator) { - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunRequest.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunRequest.java deleted file mode 100644 index 651f63c..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunRequest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import java.util.Map; - -/** - * Request data needed to dispatch and observe one GitHub Actions workflow run. - * - * @param apiUrl GitHub REST API base URL - * @param owner repository owner - * @param repo repository name - * @param workflowPath workflow file path or file name - * @param ref branch or tag used for workflow dispatch - * @param inputs workflow_dispatch input values - * @param tokenEnvVar optional environment variable used only after IDE GitHub accounts fail or are unavailable - */ -public record WorkflowRunRequest( - String apiUrl, - String owner, - String repo, - String workflowPath, - String ref, - Map inputs, - String tokenEnvVar -) { - - public WorkflowRunRequest { - inputs = Map.copyOf(inputs == null ? Map.of() : inputs); - } - - public String repositorySlug() { - return owner + "/" + repo; - } - -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditor.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditor.java deleted file mode 100644 index adff3ff..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditor.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.options.ConfigurationException; -import com.intellij.openapi.options.SettingsEditor; -import com.intellij.ui.ToolbarDecorator; -import com.intellij.ui.table.JBTable; -import org.jetbrains.annotations.NotNull; - -import javax.swing.BorderFactory; -import javax.swing.JComponent; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JTextField; -import javax.swing.table.DefaultTableModel; -import java.awt.BorderLayout; -import java.awt.GridBagConstraints; -import java.awt.GridBagLayout; -import java.awt.Insets; -import java.util.Map; -import java.util.Objects; - -/** - * Plain Swing settings editor for GitHub Workflow run configurations. - */ -public final class WorkflowRunSettingsEditor extends SettingsEditor { - - private final JPanel panel = new JPanel(new BorderLayout(8, 8)); - private final JTextField apiUrl = new JTextField(); - private final JTextField owner = new JTextField(); - private final JTextField repo = new JTextField(); - private final JTextField workflowPath = new JTextField(); - private final JTextField ref = new JTextField(); - private final JTextField tokenEnvVar = new JTextField(); - private final JPanel inputPanel = new JPanel(new BorderLayout(4, 4)); - private final DefaultTableModel inputsModel = new DefaultTableModel(new Object[][]{}, new Object[]{ - GitHubWorkflowBundle.message("documentation.name.label"), - GitHubWorkflowBundle.message("documentation.value.label") - }) { - @Override - public boolean isCellEditable(final int row, final int column) { - return true; - } - }; - private final JBTable inputsTable = new JBTable(inputsModel); - - public WorkflowRunSettingsEditor() { - final JPanel fields = new JPanel(new GridBagLayout()); - fields.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8)); - addRow(fields, 0, GitHubWorkflowBundle.message("workflow.run.field.apiUrl"), apiUrl); - addRow(fields, 1, GitHubWorkflowBundle.message("workflow.run.field.owner"), owner); - addRow(fields, 2, GitHubWorkflowBundle.message("workflow.run.field.repo"), repo); - addRow(fields, 3, GitHubWorkflowBundle.message("workflow.run.field.workflow"), workflowPath); - addRow(fields, 4, GitHubWorkflowBundle.message("workflow.run.field.ref"), ref); - addRow(fields, 5, GitHubWorkflowBundle.message("workflow.run.field.tokenEnv"), tokenEnvVar); - panel.add(fields, BorderLayout.NORTH); - - inputsTable.setFillsViewportHeight(true); - inputPanel.setBorder(BorderFactory.createTitledBorder(GitHubWorkflowBundle.message("workflow.run.inputs.title"))); - inputPanel.add(ToolbarDecorator.createDecorator(inputsTable) - .setAddAction(button -> addInputRow("", "")) - .setRemoveAction(button -> removeSelectedInputRows()) - .disableUpDownActions() - .createPanel(), BorderLayout.CENTER); - panel.add(inputPanel, BorderLayout.CENTER); - } - - @Override - protected void resetEditorFrom(@NotNull final WorkflowRunConfiguration configuration) { - apiUrl.setText(configuration.apiUrl()); - owner.setText(configuration.owner()); - repo.setText(configuration.repo()); - workflowPath.setText(configuration.workflowPath()); - ref.setText(configuration.ref()); - tokenEnvVar.setText(configuration.tokenEnvVar()); - resetInputs(configuration); - } - - @Override - protected void applyEditorTo(@NotNull final WorkflowRunConfiguration configuration) throws ConfigurationException { - configuration.apiUrl(apiUrl.getText()) - .owner(owner.getText()) - .repo(repo.getText()) - .workflowPath(workflowPath.getText()) - .ref(ref.getText()) - .tokenEnvVar(tokenEnvVar.getText()) - .inputsText(inputsText()); - } - - @Override - protected @NotNull JComponent createEditor() { - return panel; - } - - private static void addRow(final JPanel panel, final int row, final String label, final JTextField field) { - final GridBagConstraints labelConstraints = new GridBagConstraints(); - labelConstraints.gridx = 0; - labelConstraints.gridy = row; - labelConstraints.anchor = GridBagConstraints.WEST; - labelConstraints.insets = new Insets(2, 0, 2, 8); - panel.add(new JLabel(label), labelConstraints); - - final GridBagConstraints fieldConstraints = new GridBagConstraints(); - fieldConstraints.gridx = 1; - fieldConstraints.gridy = row; - fieldConstraints.weightx = 1; - fieldConstraints.fill = GridBagConstraints.HORIZONTAL; - fieldConstraints.insets = new Insets(2, 0, 2, 0); - panel.add(field, fieldConstraints); - } - - private void resetInputs(final WorkflowRunConfiguration configuration) { - inputsModel.setRowCount(0); - for (final Map.Entry entry : WorkflowDispatchInputs.parseKeyValueText(configuration.inputsText()).entrySet()) { - addInputRow(entry.getKey(), entry.getValue()); - } - } - - private void addInputRow(final String key, final String value) { - inputsModel.addRow(new Object[]{key, value}); - } - - private void removeSelectedInputRows() { - final int[] selectedRows = inputsTable.getSelectedRows(); - for (int index = selectedRows.length - 1; index >= 0; index--) { - inputsModel.removeRow(inputsTable.convertRowIndexToModel(selectedRows[index])); - } - } - - private String inputsText() { - if (inputsTable.isEditing() && inputsTable.getCellEditor() != null) { - inputsTable.getCellEditor().stopCellEditing(); - } - final StringBuilder result = new StringBuilder(); - for (int row = 0; row < inputsModel.getRowCount(); row++) { - final String key = Objects.toString(inputsModel.getValueAt(row, 0), "").trim(); - if (!key.isBlank()) { - final String value = Objects.toString(inputsModel.getValueAt(row, 1), ""); - result.append(key).append("=").append(value).append("\n"); - } - } - return result.toString(); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunTracker.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunTracker.java deleted file mode 100644 index 63086b9..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowRunTracker.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer; -import com.intellij.execution.process.ProcessHandler; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.Service; -import com.intellij.openapi.project.Project; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -/** - * Tracks workflow runs started from this project so editor gutter actions can switch between run and stop. - */ -@Service(Service.Level.PROJECT) -public final class WorkflowRunTracker { - - private final Project project; - private final ConcurrentMap runs = new ConcurrentHashMap<>(); - - public WorkflowRunTracker(@NotNull final Project project) { - this.project = project; - } - - public static WorkflowRunTracker getInstance(final Project project) { - return project.getService(WorkflowRunTracker.class); - } - - public static String key(final String workflowPath) { - return Optional.ofNullable(workflowPath).orElse("").replace('\\', '/'); - } - - public boolean isRunning(final String workflowPath) { - return runs.containsKey(key(workflowPath)); - } - - public void register(final String workflowPath, final ProcessHandler processHandler) { - runs.put(key(workflowPath), processHandler); - refreshGutters(); - } - - public void unregister(final String workflowPath, final ProcessHandler processHandler) { - runs.remove(key(workflowPath), processHandler); - refreshGutters(); - } - - public boolean stop(final String workflowPath) { - return Optional.ofNullable(runs.get(key(workflowPath))) - .map(processHandler -> { - processHandler.destroyProcess(); - return true; - }) - .orElse(false); - } - - private void refreshGutters() { - ApplicationManager.getApplication().invokeLater(() -> { - if (!project.isDisposed()) { - DaemonCodeAnalyzer.getInstance(project).settingsChanged(); - } - }); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowSyntaxSchema.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowSyntaxSchema.java deleted file mode 100644 index 08057f5..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowSyntaxSchema.java +++ /dev/null @@ -1,328 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * GitHub Actions workflow syntax completion tables from the public workflow syntax reference. - */ -final class WorkflowSyntaxSchema { - - private WorkflowSyntaxSchema() { - } - - static Map topLevelKeys() { - return mapWithBundle( - "completion.workflow.top.", - "name", - "run-name", - "on", - "permissions", - "env", - "defaults", - "concurrency", - "jobs" - ); - } - - static Map eventKeys() { - return mapWithBundle( - "completion.workflow.event.", - "branch_protection_rule", - "check_run", - "check_suite", - "create", - "delete", - "deployment", - "deployment_status", - "discussion", - "discussion_comment", - "fork", - "gollum", - "image_version", - "issue_comment", - "issues", - "label", - "merge_group", - "milestone", - "page_build", - "project", - "project_card", - "project_column", - "public", - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", - "push", - "registry_package", - "release", - "repository_dispatch", - "schedule", - "status", - "watch", - "workflow_call", - "workflow_dispatch", - "workflow_run" - ); - } - - static Map eventFilterKeys() { - return mapWithBundle( - "completion.workflow.eventFilter.", - "types", - "branches", - "branches-ignore", - "tags", - "tags-ignore", - "paths", - "paths-ignore", - "workflows", - "cron" - ); - } - - static Map eventFilterKeysFor(final String event) { - return switch (event) { - case "schedule" -> mapWithBundle("completion.workflow.eventFilter.", "cron"); - case "workflow_run" -> mapWithBundle("completion.workflow.eventFilter.", "workflows", "types", "branches", "branches-ignore"); - case "push" -> mapWithBundle("completion.workflow.eventFilter.", "branches", "branches-ignore", "tags", "tags-ignore", "paths", "paths-ignore"); - case "pull_request", "pull_request_target" -> mapWithBundle("completion.workflow.eventFilter.", "types", "branches", "branches-ignore", "paths", "paths-ignore"); - default -> eventFilterKeys(); - }; - } - - static Map eventActivityTypesFor(final String event) { - return switch (event) { - case "branch_protection_rule" -> activityTypes("created", "deleted"); - case "check_run" -> activityTypes("created", "rerequested", "completed", "requested_action"); - case "check_suite" -> activityTypes("completed"); - case "discussion" -> activityTypes( - "created", "edited", "deleted", "transferred", "pinned", "unpinned", "labeled", "unlabeled", - "locked", "unlocked", "category_changed", "answered", "unanswered" - ); - case "discussion_comment", "issue_comment", "pull_request_review_comment" -> activityTypes("created", "edited", "deleted"); - case "issues" -> activityTypes( - "opened", "edited", "deleted", "transferred", "pinned", "unpinned", "closed", "reopened", - "assigned", "unassigned", "labeled", "unlabeled", "locked", "unlocked", "milestoned", "demilestoned" - ); - case "label" -> activityTypes("created", "edited", "deleted"); - case "merge_group" -> activityTypes("checks_requested"); - case "milestone" -> activityTypes("created", "closed", "opened", "edited", "deleted"); - case "pull_request", "pull_request_target" -> activityTypes( - "assigned", "unassigned", "labeled", "unlabeled", "opened", "edited", "closed", "reopened", - "synchronize", "converted_to_draft", "locked", "unlocked", "enqueued", "dequeued", - "milestoned", "demilestoned", "ready_for_review", "review_requested", "review_request_removed", - "auto_merge_enabled", "auto_merge_disabled" - ); - case "pull_request_review" -> activityTypes("submitted", "edited", "dismissed"); - case "registry_package" -> activityTypes("published", "updated"); - case "release" -> activityTypes("published", "unpublished", "created", "edited", "deleted", "prereleased", "released"); - case "watch" -> activityTypes("started"); - case "workflow_run" -> activityTypes("completed", "requested", "in_progress"); - default -> java.util.Collections.emptyMap(); - }; - } - - static Map permissionScopes() { - return mapWithBundle( - "completion.workflow.permission.", - "actions", - "artifact-metadata", - "attestations", - "checks", - "code-quality", - "contents", - "deployments", - "discussions", - "id-token", - "issues", - "models", - "packages", - "pages", - "pull-requests", - "security-events", - "statuses", - "vulnerability-alerts" - ); - } - - static Map permissionValues() { - return mapWithBundle("completion.workflow.permission.value.", "read", "write", "none"); - } - - static Map permissionValuesFor(final String permission) { - if ("id-token".equals(permission)) { - return mapWithBundle("completion.workflow.permission.value.", "write", "none"); - } - if ("models".equals(permission) || "vulnerability-alerts".equals(permission)) { - return mapWithBundle("completion.workflow.permission.value.", "read", "none"); - } - return permissionValues(); - } - - static Map permissionShorthandValues() { - final Map values = new LinkedHashMap<>(); - values.put("read-all", "read-all"); - values.put("write-all", "write-all"); - values.put("{}", "empty"); - return mapWithBundleKeys("completion.workflow.permission.shorthand.", values); - } - - static Map jobKeys() { - return mapWithBundle( - "completion.workflow.job.", - "name", - "permissions", - "needs", - "if", - "runs-on", - "snapshot", - "environment", - "concurrency", - "outputs", - "env", - "defaults", - "steps", - "timeout-minutes", - "strategy", - "continue-on-error", - "container", - "services", - "uses", - "with", - "secrets" - ); - } - - static Map defaultsRunKeys() { - return mapWithBundle("completion.workflow.defaultsRun.", "shell", "working-directory"); - } - - static Map concurrencyKeys() { - return mapWithBundle("completion.workflow.concurrency.", "group", "cancel-in-progress"); - } - - static Map environmentKeys() { - return mapWithBundle("completion.workflow.environment.", "name", "url"); - } - - static Map strategyKeys() { - return mapWithBundle("completion.workflow.strategy.", "matrix", "fail-fast", "max-parallel"); - } - - static Map matrixKeys() { - return mapWithBundle("completion.workflow.matrix.", "include", "exclude"); - } - - static Map stepKeys() { - return mapWithBundle( - "completion.workflow.step.", - "id", - "if", - "name", - "uses", - "run", - "shell", - "with", - "env", - "continue-on-error", - "timeout-minutes", - "working-directory" - ); - } - - static Map containerKeys() { - return mapWithBundle("completion.workflow.container.", "image", "credentials", "env", "ports", "volumes", "options"); - } - - static Map serviceKeys() { - return mapWithBundle("completion.workflow.service.", "image", "credentials", "env", "ports", "volumes", "options"); - } - - static Map credentialsKeys() { - return mapWithBundle("completion.workflow.credentials.", "username", "password"); - } - - static Map workflowInputTypes() { - return mapWithBundle("completion.workflow.inputType.", "string", "boolean", "choice", "number", "environment"); - } - - static Map reusableWorkflowInputTypes() { - return mapWithBundle("completion.workflow.inputType.", "string", "boolean", "number"); - } - - static Map workflowInputPropertyKeys() { - final Map result = new LinkedHashMap<>(); - result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); - result.put("type", GitHubWorkflowBundle.message("documentation.type", "string | boolean | choice | number | environment")); - result.put("required", GitHubWorkflowBundle.message("documentation.required", true)); - result.put("default", GitHubWorkflowBundle.message("documentation.default", "")); - result.put("options", GitHubWorkflowBundle.message("documentation.value.label")); - return java.util.Collections.unmodifiableMap(result); - } - - static Map workflowOutputPropertyKeys() { - final Map result = new LinkedHashMap<>(); - result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); - result.put("value", GitHubWorkflowBundle.message("documentation.value.label")); - return java.util.Collections.unmodifiableMap(result); - } - - static Map workflowSecretPropertyKeys() { - final Map result = new LinkedHashMap<>(); - result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); - result.put("required", GitHubWorkflowBundle.message("documentation.required", true)); - return java.util.Collections.unmodifiableMap(result); - } - - static Map booleanValues() { - return mapWithBundle("completion.workflow.boolean.", "true", "false"); - } - - static Map runnerLabels() { - return mapWithBundle( - "completion.workflow.runner.", - "ubuntu-latest", - "ubuntu-24.04", - "ubuntu-22.04", - "windows-latest", - "windows-2025", - "windows-2022", - "macos-latest", - "macos-15", - "macos-14", - "self-hosted" - ); - } - - private static Map map(final String... keys) { - final Map result = new LinkedHashMap<>(); - for (final String key : keys) { - result.put(key, GitHubWorkflowBundle.message("completion.workflow.syntax")); - } - return java.util.Collections.unmodifiableMap(result); - } - - private static Map mapWithBundle(final String prefix, final String... keys) { - final Map result = new LinkedHashMap<>(); - for (final String key : keys) { - result.put(key, GitHubWorkflowBundle.message(prefix + key)); - } - return java.util.Collections.unmodifiableMap(result); - } - - private static Map mapWithBundleKeys(final String prefix, final Map keysToBundleSuffix) { - final Map result = new LinkedHashMap<>(); - keysToBundleSuffix.forEach((key, bundleSuffix) -> result.put(key, GitHubWorkflowBundle.message(prefix + bundleSuffix))); - return java.util.Collections.unmodifiableMap(result); - } - - private static Map activityTypes(final String... keys) { - final Map result = new LinkedHashMap<>(); - for (final String key : keys) { - result.put(key, GitHubWorkflowBundle.message("completion.workflow.eventFilter.types")); - } - return java.util.Collections.unmodifiableMap(result); - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowTextAttributes.java b/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowTextAttributes.java deleted file mode 100644 index 8a79383..0000000 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/WorkflowTextAttributes.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.editor.DefaultLanguageHighlighterColors; -import com.intellij.openapi.editor.colors.TextAttributesKey; - -public final class WorkflowTextAttributes { - - public static final TextAttributesKey VARIABLE_REFERENCE = TextAttributesKey.createTextAttributesKey( - "GITHUB_WORKFLOW_VARIABLE_REFERENCE", - DefaultLanguageHighlighterColors.CONSTANT - ); - - public static final TextAttributesKey DECLARATION = TextAttributesKey.createTextAttributesKey( - "GITHUB_WORKFLOW_DECLARATION", - DefaultLanguageHighlighterColors.STATIC_FIELD - ); - - public static final TextAttributesKey RUNNER_VARIABLE = TextAttributesKey.createTextAttributesKey( - "GITHUB_WORKFLOW_RUNNER_VARIABLE", - DefaultLanguageHighlighterColors.GLOBAL_VARIABLE - ); - - public static final TextAttributesKey SCALAR_LITERAL = TextAttributesKey.createTextAttributesKey( - "GITHUB_WORKFLOW_SCALAR_LITERAL", - DefaultLanguageHighlighterColors.NUMBER - ); - - private WorkflowTextAttributes() { - // constants - } -} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowSettingsConfigurable.java b/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java similarity index 95% rename from src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowSettingsConfigurable.java rename to src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java index fe16e46..21e69df 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubWorkflowSettingsConfigurable.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/settings/GitHubWorkflowSettingsConfigurable.java @@ -1,4 +1,8 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.settings; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.ide.BrowserUtil; import com.intellij.openapi.options.ConfigurationException; @@ -37,12 +41,12 @@ /** * Settings UI for locale override and GitHub Action cache maintenance. */ -public final class GitHubWorkflowSettingsConfigurable implements SearchableConfigurable { +public class GitHubWorkflowSettingsConfigurable implements SearchableConfigurable { private static final String SUPPORT_URL = "https://github.com/sponsors/YunaBraska"; private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.systemDefault()); private static final List LOCALES = List.of( - new LocaleOption(PluginSettings.SYSTEM_LANGUAGE, "settings.language.system", true), + new LocaleOption(GitHubWorkflowBundle.Settings.SYSTEM_LANGUAGE, "settings.language.system", true), new LocaleOption("ar", "Arabic"), new LocaleOption("cs", "Czech"), new LocaleOption("de", "Deutsch"), @@ -65,7 +69,7 @@ public final class GitHubWorkflowSettingsConfigurable implements SearchableConfi new LocaleOption("zh-CN", "简体中文") ); - private final PluginSettings settings = PluginSettings.getInstance(); + private final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); private final GitHubActionCache cache = GitHubActionCache.getActionCache(); private final JComboBox language = new JComboBox<>(LOCALES.toArray(LocaleOption[]::new)); private final DefaultTableModel tableModel = new DefaultTableModel(); @@ -102,7 +106,7 @@ public boolean isModified() { @Override public void apply() throws ConfigurationException { final LocaleOption option = (LocaleOption) language.getSelectedItem(); - settings.languageTag(option == null ? PluginSettings.SYSTEM_LANGUAGE : option.tag()); + settings.languageTag(option == null ? GitHubWorkflowBundle.Settings.SYSTEM_LANGUAGE : option.tag()); reloadTable(); GitHubActionCache.triggerSyntaxHighlightingForActiveFiles(); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubActionCache.java b/src/main/java/com/github/yunabraska/githubworkflow/state/GitHubActionCache.java similarity index 69% rename from src/main/java/com/github/yunabraska/githubworkflow/services/GitHubActionCache.java rename to src/main/java/com/github/yunabraska/githubworkflow/state/GitHubActionCache.java index 89b1cfc..364e32c 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/services/GitHubActionCache.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/state/GitHubActionCache.java @@ -1,25 +1,47 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.state; -import com.github.yunabraska.githubworkflow.helper.GitHubWorkflowHelper; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer; +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.Presentation; import com.intellij.openapi.application.Application; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.components.PersistentStateComponent; import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; +import com.intellij.openapi.Disposable; import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.progress.Task; +import com.intellij.openapi.project.DumbAwareAction; +import com.intellij.openapi.project.DumbService; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.project.ProjectUtil; +import com.intellij.openapi.startup.ProjectActivity; +import com.intellij.openapi.util.Disposer; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; +import com.intellij.psi.PsiTreeChangeAdapter; +import com.intellij.psi.PsiTreeChangeEvent; +import com.intellij.util.concurrency.AppExecutorUtil; +import com.intellij.util.messages.MessageBusConnection; import com.intellij.util.xmlb.XmlSerializerUtil; +import kotlin.Unit; +import kotlin.coroutines.Continuation; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.yaml.psi.YAMLKeyValue; @@ -32,6 +54,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Base64; import java.util.Collection; import java.util.HashMap; @@ -42,18 +65,19 @@ import java.util.Optional; import java.util.Properties; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.CACHE_ONE_DAY; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getProject; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.toPath; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.CACHE_ONE_DAY; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getProject; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.toPath; import static com.github.yunabraska.githubworkflow.model.GitHubAction.createGithubAction; import static com.github.yunabraska.githubworkflow.model.GitHubAction.findActionYaml; -import static com.github.yunabraska.githubworkflow.services.ProjectStartup.threadPoolExec; import static java.util.Optional.ofNullable; @SuppressWarnings("UnusedReturnValue") @@ -61,6 +85,7 @@ public class GitHubActionCache implements PersistentStateComponent { private static final String DEFAULT_REMOTE_REF = "main"; + private static final String EXPORT_HEADER = "github-workflow-cache-v1"; public static class State { public final Map actions = new ConcurrentHashMap<>(); @@ -112,7 +137,7 @@ public void cleanUp() { }); } - protected GitHubAction get(final Project project, final String usesValue) { + public GitHubAction get(final Project project, final String usesValue) { final String usesCleaned = usesValue.replace("IntellijIdeaRulezzz", ""); final boolean isLocal = isLocalUses(usesCleaned); final String normalizedUses = normalizeUsesValue(usesCleaned, isLocal); @@ -172,7 +197,7 @@ public List entries() { public CacheSummary removeAll(final Collection keys) { ofNullable(keys).stream() .flatMap(Collection::stream) - .filter(PsiElementHelper::hasText) + .filter(WorkflowPsi::hasText) .forEach(state.actions::remove); triggerSyntaxHighlightingForActiveFiles(); return summary(); @@ -193,7 +218,7 @@ public long estimatedSizeBytes() { */ public CacheSummary exportCache(final Path output) throws IOException { try (BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) { - writer.write("github-workflow-cache-v1"); + writer.write(EXPORT_HEADER); writer.newLine(); for (final Map.Entry entry : new LinkedHashMap<>(state.actions).entrySet()) { writer.write(encode(entry.getKey())); @@ -221,7 +246,7 @@ public CacheSummary exportCache(final Path output) throws IOException { public CacheSummary importCache(final Path input) throws IOException { try (BufferedReader reader = Files.newBufferedReader(input, StandardCharsets.UTF_8)) { final String header = reader.readLine(); - if (!"github-workflow-cache-v1".equals(header)) { + if (!EXPORT_HEADER.equals(header)) { throw new IOException(GitHubWorkflowBundle.message("settings.cache.import.unsupported")); } String line; @@ -315,7 +340,7 @@ public GitHubAction reloadAsync(final Project project, final String usesValue) { .map(state.actions::get) .map(oldAction -> saveNewAction(project, oldAction)) .map(action -> { - threadPoolExec(project, () -> { + smartExecute(project, () -> { actionResolver.get().resolve(action); triggerSyntaxHighlightingForActiveFiles(); }); @@ -326,13 +351,7 @@ public GitHubAction reloadAsync(final Project project, final String usesValue) { // !!! Performs Network and File Operations !!! public void resolveAsync(final Collection actions) { - if (actions == null || actions.isEmpty()) { - return; - } - final List queuedActions = actions.stream() - .filter(Objects::nonNull) - .filter(action -> inFlightResolutions.add(action.usesValue())) - .toList(); + final List queuedActions = queuedActions(actions); if (queuedActions.isEmpty()) { return; } @@ -351,11 +370,7 @@ public void run(@NotNull final ProgressIndicator indicator) { GitHubWorkflowBundle.message(action.isAction() ? "workflow.cache.kind.action" : "workflow.cache.kind.workflow"), action.name() )); - jitterBeforeRemoteRequest(action); - actionResolver.get().resolve(action); - if (action.isResolved()) { - action.expiryTime(System.currentTimeMillis() + (CACHE_ONE_DAY * 14)); - } + resolveQueuedAction(action); } catch (final Exception ignored) { // Keep the cache stable when a remote action fails to answer. } finally { @@ -374,24 +389,14 @@ public void run(@NotNull final ProgressIndicator indicator) { } private void resolveInBackground(final Collection actions) { - if (actions == null || actions.isEmpty()) { - return; - } - final List queuedActions = actions.stream() - .filter(Objects::nonNull) - .filter(action -> inFlightResolutions.add(action.usesValue())) - .toList(); + final List queuedActions = queuedActions(actions); if (queuedActions.isEmpty()) { return; } ApplicationManager.getApplication().executeOnPooledThread(() -> { queuedActions.forEach(action -> { try { - jitterBeforeRemoteRequest(action); - actionResolver.get().resolve(action); - if (action.isResolved()) { - action.expiryTime(System.currentTimeMillis() + (CACHE_ONE_DAY * 14)); - } + resolveQueuedAction(action); } catch (final Exception ignored) { // Automatic refresh must never block editing because a network target misbehaved. } finally { @@ -402,7 +407,23 @@ private void resolveInBackground(final Collection actions) { }); } - ActionResolver useActionResolverForTests(final ActionResolver resolver) { + private List queuedActions(final Collection actions) { + return ofNullable(actions).stream() + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .filter(action -> inFlightResolutions.add(action.usesValue())) + .toList(); + } + + private void resolveQueuedAction(final GitHubAction action) { + jitterBeforeRemoteRequest(action); + actionResolver.get().resolve(action); + if (action.isResolved()) { + action.expiryTime(System.currentTimeMillis() + (CACHE_ONE_DAY * 14)); + } + } + + public ActionResolver useActionResolverForTests(final ActionResolver resolver) { return actionResolver.getAndSet(ofNullable(resolver).orElse(GitHubAction::resolve)); } @@ -431,7 +452,7 @@ private static void triggerSyntaxHighlightingForActiveFiles(final Project projec final DaemonCodeAnalyzer daemonCodeAnalyzer = DaemonCodeAnalyzer.getInstance(project); final boolean hasActiveWorkflowFile = Stream.of(FileEditorManager.getInstance(project).getSelectedFiles()) .filter(VirtualFile::isValid) - .filter(virtualFile -> toPath(virtualFile).map(GitHubWorkflowHelper::isWorkflowPath).orElse(false)) + .filter(virtualFile -> toPath(virtualFile).map(WorkflowYaml::isWorkflowPath).orElse(false)) .map(virtualFile -> PsiManager.getInstance(project).findFile(virtualFile)) .filter(Objects::nonNull) .filter(PsiFile::isValid) @@ -477,6 +498,14 @@ public static Optional isUseElement(final PsiElement psiElement) { .filter(keyValue -> FIELD_USES.equals(keyValue.getKeyText())); } + private static void smartExecute(final Project project, final Runnable task) { + if (!DumbService.isDumb(project)) { + AppExecutorUtil.getAppExecutorService().execute(task); + } else { + DumbService.getInstance(project).runWhenSmart(() -> AppExecutorUtil.getAppExecutorService().execute(task)); + } + } + private GitHubAction saveNewAction(final Project project, final GitHubAction oldAction) { final boolean isLocal = isLocalUses(oldAction.usesValue()); final String normalizedUses = normalizeUsesValue(oldAction.usesValue(), isLocal); @@ -510,7 +539,7 @@ private String getAbsolutePath(final boolean isLocal, final String subPath, fina return !isLocal ? subPath : ofNullable(project) .map(ProjectUtil::guessProjectDir) .map(projectDir -> findActionYaml(subPath, projectDir)) - .flatMap(PsiElementHelper::toPath) + .flatMap(WorkflowPsi::toPath) .map(Path::toString) .orElse(subPath); } @@ -538,11 +567,11 @@ private static Optional getUsesString(final PsiElement psiElement) { } private static Optional getUsesValue(final PsiElement psiElement) { - return isUseElement(psiElement).flatMap(PsiElementHelper::getText); + return isUseElement(psiElement).flatMap(WorkflowPsi::getText); } private static Optional getChildWithUsesValue(final PsiElement psiElement) { - return ofNullable(psiElement).filter(PsiElement::isValid).flatMap(element -> PsiElementHelper.getChild(element, FIELD_USES)).flatMap(PsiElementHelper::getText); + return ofNullable(psiElement).filter(PsiElement::isValid).flatMap(element -> WorkflowPsi.getChild(element, FIELD_USES)).flatMap(WorkflowPsi::getText); } private static long estimate(final String value) { @@ -579,9 +608,180 @@ private static Map decode(final String value) throws IOException return result; } + public static class Startup implements ProjectActivity { + + @Nullable + @Override + public Object execute(@NotNull final Project project, @NotNull final Continuation continuation) { + final Disposable listenerDisposable = Disposer.newDisposable(); + Disposer.register(project, listenerDisposable); + + PsiManager.getInstance(project).addPsiTreeChangeListener(new ActionMetadataChangeListener(), listenerDisposable); + + final FileEditorManager fileEditorManager = FileEditorManager.getInstance(project); + for (final VirtualFile openedFile : fileEditorManager.getOpenFiles()) { + asyncInitAllActions(project, openedFile); + } + + final MessageBusConnection connection = project.getMessageBus().connect(listenerDisposable); + connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() { + @Override + public void fileOpened(@NotNull final FileEditorManager source, @NotNull final VirtualFile file) { + asyncInitAllActions(project, file); + } + }); + + final ScheduledFuture cleanupTask = AppExecutorUtil.getAppScheduledExecutorService() + .scheduleWithFixedDelay(() -> getActionCache().cleanUp(), 0, 30, TimeUnit.MINUTES); + Disposer.register(listenerDisposable, () -> cleanupTask.cancel(false)); + return null; + } + } + + private static class ActionMetadataChangeListener extends PsiTreeChangeAdapter { + @Override + public void childReplaced(@NotNull final PsiTreeChangeEvent event) { + ofNullable(event.getNewChild()) + .filter(psiElement -> WorkflowYaml.getWorkflowFile(psiElement).isPresent()) + .flatMap(psiElement -> WorkflowPsi.getParent(psiElement, FIELD_USES)) + .map(GitHubActionCache::getAction) + .filter(action -> !action.isResolved()) + .map(List::of) + .ifPresent(GitHubActionCache::resolveActionsAsync); + } + + @Override + public void childrenChanged(@NotNull final PsiTreeChangeEvent event) { + ofNullable(event.getParent()) + .filter(psiElement -> WorkflowYaml.getWorkflowFile(psiElement).isPresent()) + .map(psiElement -> WorkflowPsi.getAllElements(psiElement, FIELD_USES)) + .map(usesList -> usesList.stream() + .map(GitHubActionCache::getAction) + .filter(Objects::nonNull) + .filter(action -> !action.isLocal()) + .filter(action -> !action.isResolved()) + .toList()) + .ifPresent(GitHubActionCache::resolveActionsAsync); + } + } + + private static void asyncInitAllActions(final Project project, final VirtualFile virtualFile) { + final Runnable task = () -> { + if (virtualFile != null && virtualFile.isValid() + && toPath(virtualFile).map(WorkflowYaml::isWorkflowPath).orElse(false)) { + ReadAction.nonBlocking(() -> unresolvedActions(project, virtualFile)) + .inSmartMode(project) + .submit(AppExecutorUtil.getAppExecutorService()) + .onSuccess(GitHubActionCache::resolveActionsAsync); + } + }; + smartExecute(project, task); + } + + private static List unresolvedActions(final Project project, final VirtualFile virtualFile) { + final List actions = new ArrayList<>(); + Optional.of(PsiManager.getInstance(project)) + .map(psiManager -> psiManager.findFile(virtualFile)) + .map(psiFile -> WorkflowPsi.getAllElements(psiFile, FIELD_USES)) + .ifPresent(usesList -> usesList.stream() + .map(GitHubActionCache::getAction) + .filter(Objects::nonNull) + .filter(action -> !action.isSuppressed()) + .filter(action -> !action.isResolved()) + .forEach(actions::add)); + return actions; + } + public record CacheSummary(long total, long resolved, long remote, long expired, long suppressed) { } + public static class ClearAction extends CacheAction { + + public ClearAction() { + super("ClearActionCache"); + } + + @Override + public void actionPerformed(@NotNull final AnActionEvent event) { + final CacheSummary before = getActionCache().summary(); + getActionCache().clear(); + notify(event, GitHubWorkflowBundle.message("notification.cache.cleared", before.total())); + } + } + + public static class RefreshAction extends CacheAction { + + public RefreshAction() { + super("RefreshActionCache"); + } + + @Override + public void actionPerformed(@NotNull final AnActionEvent event) { + final CacheSummary before = getActionCache().summary(); + getActionCache().refreshResolvedRemoteActions(); + notify(event, GitHubWorkflowBundle.message("notification.cache.refresh.started", before.remote())); + } + + @Override + protected boolean enabled(final CacheSummary summary) { + return summary.remote() > 0; + } + } + + public static class RestoreWarningsAction extends CacheAction { + + public RestoreWarningsAction() { + super("RestoreActionWarnings"); + } + + @Override + public void actionPerformed(@NotNull final AnActionEvent event) { + final long restored = getActionCache().restoreWarnings(); + notify(event, GitHubWorkflowBundle.message("notification.warnings.restored", restored)); + } + + @Override + protected boolean enabled(final CacheSummary summary) { + return summary.suppressed() > 0; + } + } + + private abstract static class CacheAction extends DumbAwareAction { + + private final String key; + + private CacheAction(final String key) { + this.key = key; + } + + @Override + public void update(@NotNull final AnActionEvent event) { + localize(event.getPresentation()); + event.getPresentation().setEnabled(enabled(getActionCache().summary())); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + protected boolean enabled(final CacheSummary summary) { + return true; + } + + protected final void notify(final AnActionEvent event, final String content) { + NotificationGroupManager.getInstance() + .getNotificationGroup("GitHub Workflow") + .createNotification(content, NotificationType.INFORMATION) + .notify(event.getProject()); + } + + private void localize(final Presentation presentation) { + presentation.setText(GitHubWorkflowBundle.message("action.GitHubWorkflow." + key + ".text")); + presentation.setDescription(GitHubWorkflowBundle.message("action.GitHubWorkflow." + key + ".description")); + } + } + public record CacheEntry( String key, String name, diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Action.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Action.java similarity index 86% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Action.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Action.java index f64eaaa..b3902ae 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Action.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Action.java @@ -1,12 +1,14 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.github.yunabraska.githubworkflow.model.IconRenderer; import com.github.yunabraska.githubworkflow.model.LocalActionReferenceResolver; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; -import com.github.yunabraska.githubworkflow.services.GitHubActionCache; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.codeInspection.ProblemHighlightType; import com.intellij.lang.annotation.AnnotationBuilder; import com.intellij.lang.annotation.AnnotationHolder; @@ -26,23 +28,23 @@ import java.util.Optional; import java.util.Set; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SECRETS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_WITH; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.addAnnotation; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.deleteElementAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.deleteInvalidAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.newJumpToFile; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.newReloadAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.newUnresolvedAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.replaceAction; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStepOrJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getTextElement; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.goToDeclarationString; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.toYAMLKeyValue; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SECRETS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_WITH; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.addAnnotation; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.deleteElementAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.deleteInvalidAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.newJumpToFile; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.newReloadAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.newUnresolvedAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.replaceAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStepOrJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getTextElement; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.goToDeclarationString; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.toYAMLKeyValue; import static com.github.yunabraska.githubworkflow.model.NodeIcon.EMPTY; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_OUTPUT; import static com.github.yunabraska.githubworkflow.model.NodeIcon.IGNORED; @@ -51,8 +53,8 @@ import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_ON; import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_OFF; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.triggerSyntaxHighlightingForActiveFiles; -import static com.github.yunabraska.githubworkflow.services.ReferenceContributor.ACTION_KEY; +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.triggerSyntaxHighlightingForActiveFiles; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowReferences.ACTION_KEY; import static java.util.Optional.ofNullable; public class Action { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Envs.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java similarity index 77% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Envs.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java index 4589358..690b7da 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Envs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Envs.java @@ -1,6 +1,6 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.openapi.util.TextRange; @@ -15,16 +15,16 @@ import java.util.function.Function; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ENVS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUN; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStep; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.DEFAULT_VALUE_MAP; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ENVS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUN; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStep; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV_JOB; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV_ROOT; @@ -65,7 +65,7 @@ private static void addRunEnvs(final PsiElement psiElement, final List getParentStep(keyValue).map(PsiElement::getTextRange).map(TextRange::getStartOffset).orElse(currentRange.getEndOffset()) < currentRange.getStartOffset()) - .map(PsiElementHelper::parseEnvVariables) + .map(WorkflowPsi::parseEnvVariables) .flatMap(Collection::stream) .collect(Collectors.toMap(SimpleElement::key, SimpleElement::textNoQuotes, (existing, replacement) -> existing)) , ICON_TEXT_VARIABLE @@ -74,7 +74,7 @@ private static void addRunEnvs(final PsiElement psiElement, final List result) { getChild(psiElement.getContainingFile(), FIELD_ENVS) - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .map(toMapWithKeyAndText()) .map(map -> completionItemsOf(map, ICON_ENV_ROOT)) .ifPresent(result::addAll); @@ -83,7 +83,7 @@ private static void addWorkflowEnvs(final PsiElement psiElement, final List result) { getParentJob(psiElement) .flatMap(job -> getChild(job, FIELD_ENVS)) - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .map(toMapWithKeyAndText()) .map(map -> completionItemsOf(map, ICON_ENV_JOB)) .ifPresent(result::addAll); @@ -92,7 +92,7 @@ private static void addJobEnvs(final PsiElement psiElement, final List result) { getParentStep(psiElement) .flatMap(step -> getChild(step, FIELD_ENVS)) - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .map(toMapWithKeyAndText()) .map(map -> completionItemsOf(map, ICON_ENV_STEP)) .ifPresent(result::addAll); diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Inputs.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Inputs.java similarity index 74% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Inputs.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Inputs.java index 3ed90ef..4ca7ff4 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Inputs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Inputs.java @@ -1,6 +1,6 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.psi.PsiElement; @@ -13,11 +13,11 @@ import java.util.List; import java.util.Map; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_INPUTS; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_INPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_INPUT; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; @@ -46,7 +46,7 @@ public static List listInputs(final PsiElement psiElement) { @NotNull public static List listInputsRaw(final PsiElement psiElement) { return getAllElements(psiElement.getContainingFile(), FIELD_INPUTS).stream() - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .flatMap(Collection::stream) .toList(); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/JobContext.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/JobContext.java similarity index 84% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/JobContext.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/JobContext.java index 1d62895..b87b5f3 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/JobContext.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/JobContext.java @@ -1,7 +1,7 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; import com.github.yunabraska.githubworkflow.model.SimpleElement; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.psi.PsiElement; import com.intellij.psi.impl.source.tree.LeafPsiElement; @@ -13,17 +13,17 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_JOB; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SERVICES; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isField2Valid; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isValidItem3; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getTextElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.removeQuotes; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.DEFAULT_VALUE_MAP; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_JOB; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SERVICES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isField2Valid; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isValidItem3; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getTextElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.removeQuotes; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_JOB; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_NODE; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; @@ -82,7 +82,7 @@ public static List codeCompletionJob(final String parent, final S public static List listServiceIds(final PsiElement psiElement) { return getParentJob(psiElement) .flatMap(job -> getChild(job, FIELD_SERVICES)) - .map(services -> com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildren(services).stream() + .map(services -> com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildren(services).stream() .map(YAMLKeyValue::getKeyText) .toList()) .orElseGet(List::of); @@ -91,7 +91,7 @@ public static List listServiceIds(final PsiElement psiElement) { public static Optional getService(final PsiElement psiElement, final String serviceId) { return getParentJob(psiElement) .flatMap(job -> getChild(job, FIELD_SERVICES)) - .flatMap(services -> com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildren(services).stream() + .flatMap(services -> com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildren(services).stream() .filter(service -> serviceId.equals(service.getKeyText())) .findFirst()); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Jobs.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Jobs.java similarity index 66% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Jobs.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Jobs.java index d7455ae..61bfde4 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Jobs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Jobs.java @@ -1,6 +1,6 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.NodeIcon; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.lang.annotation.AnnotationHolder; @@ -13,22 +13,22 @@ import java.util.Objects; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ID; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_JOBS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RESULT; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isField2Valid; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isValidItem3; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildren; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; -import static com.github.yunabraska.githubworkflow.logic.Action.listActionsOutputs; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ID; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_JOBS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RESULT; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isField2Valid; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isValidItem3; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildren; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.syntax.Action.listActionsOutputs; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_OUTPUT; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemOf; import static java.util.Optional.ofNullable; @@ -74,7 +74,7 @@ public static List listJobOutputs(final YAMLKeyValue job) { //JOB OUTPUTS final List jobOutputs = ofNullable(job) .flatMap(j -> getChild(j, FIELD_OUTPUTS) - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .map(children -> children.stream().map(child -> getText(child).map(value -> completionItemOf(child.getKeyText(), value, ICON_OUTPUT)).orElse(null)).filter(Objects::nonNull).toList()) ).orElseGet(Collections::emptyList); @@ -83,11 +83,11 @@ public static List listJobOutputs(final YAMLKeyValue job) { } public static SimpleElement jobToCompletionItem(final YAMLKeyValue item) { - final List children = PsiElementHelper.getChildren(item); + final List children = WorkflowPsi.getChildren(item); final YAMLKeyValue usesOrName = children.stream().filter(child -> FIELD_USES.equals(child.getKeyText())).findFirst().orElseGet(() -> children.stream().filter(child -> "name".equals(child.getKeyText())).findFirst().orElse(null)); return completionItemOf( - children.stream().filter(child -> FIELD_ID.equals(child.getKeyText())).findFirst().flatMap(PsiElementHelper::getText).orElse(item.getKeyText()), - ofNullable(usesOrName).flatMap(PsiElementHelper::getText).orElse(""), + children.stream().filter(child -> FIELD_ID.equals(child.getKeyText())).findFirst().flatMap(WorkflowPsi::getText).orElse(item.getKeyText()), + ofNullable(usesOrName).flatMap(WorkflowPsi::getText).orElse(""), NodeIcon.ICON_NEEDS ); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Matrix.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Matrix.java similarity index 64% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Matrix.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Matrix.java index 0c6bf69..b54f015 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Matrix.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Matrix.java @@ -1,6 +1,6 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.psi.PsiElement; @@ -12,12 +12,12 @@ import java.util.List; import java.util.Map; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_MATRIX; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STRATEGY; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_MATRIX; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_STRATEGY; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_NODE; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemsOf; @@ -37,15 +37,15 @@ private static Map listMatrixRaw(final PsiElement psiElement) { .flatMap(job -> getChild(job, FIELD_STRATEGY)) .flatMap(strategy -> getChild(strategy, FIELD_MATRIX)) .ifPresent(matrix -> { - PsiElementHelper.getChildren(matrix).stream() + WorkflowPsi.getChildren(matrix).stream() .filter(Matrix::isMatrixProperty) - .forEach(property -> result.putIfAbsent(property.getKeyText(), PsiElementHelper.getText(property).orElse(""))); + .forEach(property -> result.putIfAbsent(property.getKeyText(), WorkflowPsi.getText(property).orElse(""))); getChild(matrix, "include") - .map(include -> PsiElementHelper.getChildren(include, YAMLSequenceItem.class)) + .map(include -> WorkflowPsi.getChildren(include, YAMLSequenceItem.class)) .stream() .flatMap(List::stream) - .flatMap(item -> PsiElementHelper.getChildren(item).stream()) - .forEach(property -> result.putIfAbsent(property.getKeyText(), PsiElementHelper.getText(property).orElse(""))); + .flatMap(item -> WorkflowPsi.getChildren(item).stream()) + .forEach(property -> result.putIfAbsent(property.getKeyText(), WorkflowPsi.getText(property).orElse(""))); }); return result; } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Needs.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Needs.java similarity index 77% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Needs.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Needs.java index 6012c35..5cf52ab 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Needs.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Needs.java @@ -1,10 +1,10 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.LocalReferenceResolver; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.lang.annotation.HighlightSeverity; import com.intellij.openapi.editor.DefaultLanguageHighlighterColors; @@ -19,21 +19,21 @@ import java.util.Objects; import java.util.Optional; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_NEEDS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RESULT; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.addAnnotation; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.deleteElementAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isField2Valid; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isValidItem3; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getTextElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.goToDeclarationString; -import static com.github.yunabraska.githubworkflow.logic.Jobs.listAllJobs; -import static com.github.yunabraska.githubworkflow.logic.Jobs.listJobOutputs; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_NEEDS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RESULT; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.addAnnotation; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.deleteElementAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isField2Valid; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isValidItem3; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getTextElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.goToDeclarationString; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.listAllJobs; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.listJobOutputs; import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_ON; import static java.util.Optional.ofNullable; @@ -60,7 +60,7 @@ public static void highlightNeeds(final AnnotationHolder holder, final LeafPsiEl // needs field public static void highlightNeeds(final AnnotationHolder holder, final PsiElement psiElement) { ofNullable(psiElement) - .filter(PsiElementHelper::isTextElement) + .filter(WorkflowPsi::isTextElement) .filter(element -> getParent(element, FIELD_NEEDS).isPresent()) .ifPresent(element -> { final List jobsNames = listJobs(psiElement).stream().map(YAMLKeyValue::getKeyText).toList(); @@ -127,8 +127,8 @@ public static List listJobNeeds(final PsiElement psiElement) { return getJobNeed(psiElement) .map(needs -> getTextElements(needs) .stream().map(PsiElement::getText) - .map(PsiElementHelper::removeQuotes) - .filter(PsiElementHelper::hasText) + .map(WorkflowPsi::removeQuotes) + .filter(WorkflowPsi::hasText) .toList() ).orElseGet(Collections::emptyList); } @@ -136,7 +136,7 @@ public static List listJobNeeds(final PsiElement psiElement) { @NotNull public static Optional getJobNeed(final PsiElement psiElement) { return ofNullable(psiElement) - .flatMap(PsiElementHelper::getParentJob) + .flatMap(WorkflowPsi::getParentJob) .flatMap(job -> getChild(job, FIELD_NEEDS)); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Secrets.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java similarity index 76% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Secrets.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java index 82022c2..361425c 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Secrets.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Secrets.java @@ -1,8 +1,8 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.codeInspection.ProblemHighlightType; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.lang.annotation.HighlightSeverity; @@ -17,18 +17,18 @@ import java.util.Map; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_IF; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ON; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_SECRETS; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.deleteElementAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.replaceAction; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.simpleTextRange; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getAllElements; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildren; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_IF; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SECRETS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.deleteElementAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.replaceAction; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.simpleTextRange; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getAllElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildren; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_SECRET_WORKFLOW; import static com.github.yunabraska.githubworkflow.model.NodeIcon.RELOAD; import static com.github.yunabraska.githubworkflow.model.NodeIcon.SUPPRESS_ON; diff --git a/src/main/java/com/github/yunabraska/githubworkflow/logic/Steps.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Steps.java similarity index 67% rename from src/main/java/com/github/yunabraska/githubworkflow/logic/Steps.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/Steps.java index 4fe4183..3c5fe4b 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/logic/Steps.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/Steps.java @@ -1,6 +1,6 @@ -package com.github.yunabraska.githubworkflow.logic; +package com.github.yunabraska.githubworkflow.syntax; -import com.github.yunabraska.githubworkflow.helper.PsiElementHelper; +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.psi.PsiElement; @@ -14,28 +14,28 @@ import java.util.Objects; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_CONCLUSION; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ID; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTCOME; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUN; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUNS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STEPS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.VALID_OUTPUT_FIELDS; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.VALID_STEP_FIELDS; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.ifEnoughItems; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isDefinedItem0; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isField2Valid; -import static com.github.yunabraska.githubworkflow.helper.HighlightAnnotatorHelper.isValidItem3; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChild; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getChildSteps; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParent; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentJob; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getParentStep; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.getText; -import static com.github.yunabraska.githubworkflow.logic.Action.highlightActionOutputs; -import static com.github.yunabraska.githubworkflow.logic.Action.listActionsOutputs; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_CONCLUSION; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ID; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTCOME; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUN; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUNS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_STEPS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.VALID_OUTPUT_FIELDS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.VALID_STEP_FIELDS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.ifEnoughItems; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isDefinedItem0; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isField2Valid; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowAnnotations.isValidItem3; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChildSteps; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStep; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.syntax.Action.highlightActionOutputs; +import static com.github.yunabraska.githubworkflow.syntax.Action.listActionsOutputs; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_STEP; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_TEXT_VARIABLE; import static com.github.yunabraska.githubworkflow.model.SimpleElement.completionItemOf; @@ -70,10 +70,10 @@ private static void ifEnoughStepItems(final AnnotationHolder holder, final PsiEl // ########## CODE COMPLETION ########## public static List codeCompletionSteps(final PsiElement psiElement) { return listSteps(psiElement).stream().map(item -> { - final List children = PsiElementHelper.getChildren(item); - return children.stream().filter(child -> FIELD_ID.equals(child.getKeyText())).findFirst().flatMap(PsiElementHelper::getText).map(stepId -> completionItemOf( + final List children = WorkflowPsi.getChildren(item); + return children.stream().filter(child -> FIELD_ID.equals(child.getKeyText())).findFirst().flatMap(WorkflowPsi::getText).map(stepId -> completionItemOf( stepId, - children.stream().filter(child -> FIELD_USES.equals(child.getKeyText())).findFirst().flatMap(PsiElementHelper::getText).orElseGet(() -> children.stream().filter(child -> "name".equals(child.getKeyText())).findFirst().flatMap(PsiElementHelper::getText).orElse(null)), + children.stream().filter(child -> FIELD_USES.equals(child.getKeyText())).findFirst().flatMap(WorkflowPsi::getText).orElseGet(() -> children.stream().filter(child -> "name".equals(child.getKeyText())).findFirst().flatMap(WorkflowPsi::getText).orElse(null)), ICON_STEP )).orElse(null); }).filter(Objects::nonNull).toList(); @@ -104,7 +104,7 @@ public static List listSteps(final PsiElement psiElement) { .map(outputs -> psiElement.getContainingFile()) .flatMap(psiFile -> getChild(psiFile, FIELD_RUNS)) .flatMap(runs -> getChild(runs, FIELD_STEPS)) - .map(PsiElementHelper::getChildSteps) + .map(WorkflowPsi::getChildSteps) .orElseGet(Collections::emptyList)) ); } @@ -117,7 +117,7 @@ public static List listStepOutputs(final YAMLSequenceItem step) { @NotNull private static List listRunOutputs(final YAMLSequenceItem step) { return ofNullable(step).flatMap(s -> getChild(s, FIELD_RUN) - .map(PsiElementHelper::parseOutputVariables) + .map(WorkflowPsi::parseOutputVariables) .map(outputs -> outputs.stream().map(output -> completionItemOf(output.key(), output.text(), ICON_TEXT_VARIABLE)).toList()) ).orElseGet(Collections::emptyList); } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/HighlightAnnotatorHelper.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowAnnotations.java similarity index 93% rename from src/main/java/com/github/yunabraska/githubworkflow/helper/HighlightAnnotatorHelper.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowAnnotations.java index 225ae93..372c67f 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/HighlightAnnotatorHelper.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowAnnotations.java @@ -1,11 +1,13 @@ -package com.github.yunabraska.githubworkflow.helper; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.github.yunabraska.githubworkflow.model.QuickFixExecution; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.github.yunabraska.githubworkflow.model.SyntaxAnnotation; -import com.github.yunabraska.githubworkflow.services.GitHubActionCache; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; import com.intellij.codeInspection.ProblemHighlightType; import com.intellij.ide.util.PsiNavigationSupport; import com.intellij.lang.injection.InjectedLanguageManager; @@ -32,11 +34,11 @@ import java.util.function.Consumer; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_CONCLUSION; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTCOME; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_OUTPUTS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_USES; -import static com.github.yunabraska.githubworkflow.helper.PsiElementHelper.removeQuotes; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_CONCLUSION; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTCOME; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.removeQuotes; import static com.github.yunabraska.githubworkflow.model.NodeIcon.JUMP_TO_IMPLEMENTATION; import static com.github.yunabraska.githubworkflow.model.NodeIcon.RELOAD; import static com.github.yunabraska.githubworkflow.model.NodeIcon.SETTINGS; @@ -44,12 +46,12 @@ import static com.github.yunabraska.githubworkflow.model.SyntaxAnnotation.createAnnotation; import static java.util.Optional.ofNullable; -public class HighlightAnnotatorHelper { +public class WorkflowAnnotations { public static final List VALID_OUTPUT_FIELDS = List.of(FIELD_OUTPUTS); public static final List VALID_STEP_FIELDS = List.of(FIELD_OUTPUTS, FIELD_CONCLUSION, FIELD_OUTCOME); - private HighlightAnnotatorHelper() { + private WorkflowAnnotations() { // static helper class } @@ -126,7 +128,7 @@ public static boolean isField2Valid(@NotNull final PsiElement psiElement, @NotNu public static void isValidItem3(@NotNull final PsiElement psiElement, @NotNull final AnnotationHolder holder, final SimpleElement itemId, final List outputs) { if (!isEmpty(outputs, itemId, psiElement, holder) && itemId != null && !outputs.contains(itemId.text())) { final TextRange textRange = simpleTextRange(psiElement, itemId); - createAnnotation(psiElement, textRange, holder, outputs.stream().filter(PsiElementHelper::hasText).map(item -> new SyntaxAnnotation( + createAnnotation(psiElement, textRange, holder, outputs.stream().filter(WorkflowPsi::hasText).map(item -> new SyntaxAnnotation( GitHubWorkflowBundle.message("inspection.replace.with", item), RELOAD, replaceAction(textRange, item) @@ -257,7 +259,7 @@ private static boolean isEmpty(final Collection items, final SimpleEleme private static void resolveAction(final YAMLKeyValue element) { ApplicationManager.getApplication().invokeLater(() -> ofNullable(element) .filter(PsiElement::isValid) - .flatMap(psiElement -> PsiElementHelper.getParent(psiElement, FIELD_USES)) + .flatMap(psiElement -> WorkflowPsi.getParent(psiElement, FIELD_USES)) .map(GitHubActionCache::getAction) .filter(action -> !action.isResolved()) .map(List::of) diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfig.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java similarity index 87% rename from src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfig.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java index 234da67..5763958 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfig.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowContextCatalog.java @@ -1,7 +1,8 @@ -package com.github.yunabraska.githubworkflow.helper; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; import java.io.BufferedReader; import java.io.IOException; @@ -15,7 +16,7 @@ import java.util.regex.Pattern; @SuppressWarnings("java:S2386") -public class GitHubWorkflowConfig { +public class WorkflowContextCatalog { public static final Pattern PATTERN_GITHUB_OUTPUT = Pattern.compile("(?:echo\\s+)?[\"']([A-Za-z_][A-Za-z0-9_-]*)=(.*?)[\"']\\s*>>\\s*\"?\\$\\w*:?\\{?GITHUB_OUTPUT\\}?\"?"); public static final Pattern PATTERN_GITHUB_OUTPUT_TEE = Pattern.compile("(?:echo\\s+)?[\"']([A-Za-z_][A-Za-z0-9_-]*)=(.*?)[\"']\\s*\\|\\s*tee\\s+(?:-[A-Za-z]+\\s+)*.*\\$\\w*:?\\{?GITHUB_OUTPUT\\}?"); @@ -52,25 +53,17 @@ public class GitHubWorkflowConfig { public static final String FIELD_CONCLUSION = "conclusion"; public static final String FIELD_OUTCOME = "outcome"; public static final Map>> DEFAULT_VALUE_MAP = initProcessorMap(); - - /** - * Returns shell completion values with descriptions localized at call time. - * - * @return immutable shell command descriptions for the current plugin language setting - */ - public static Map shells() { - return initShells(); - } + public static final Map SHELLS = initShells(); private static Map>> initProcessorMap() { final Map>> result = new LinkedHashMap<>(); - result.put(FIELD_GITHUB, GitHubWorkflowConfig::getGitHubContextEnvs); - result.put(FIELD_GITEA, GitHubWorkflowConfig::getGitHubContextEnvs); - result.put(FIELD_JOB, GitHubWorkflowConfig::getJobItems); - result.put(FIELD_ENVS, GitHubWorkflowConfig::getGitHubEnvs); - result.put(FIELD_RUNNER, GitHubWorkflowConfig::getRunnerItems); - result.put(FIELD_STRATEGY, GitHubWorkflowConfig::getStrategyItems); - result.put(FIELD_DEFAULT, GitHubWorkflowConfig::getCaretBracketItems); + result.put(FIELD_GITHUB, WorkflowContextCatalog::getGitHubContextEnvs); + result.put(FIELD_GITEA, WorkflowContextCatalog::getGitHubContextEnvs); + result.put(FIELD_JOB, WorkflowContextCatalog::getJobItems); + result.put(FIELD_ENVS, WorkflowContextCatalog::getGitHubEnvs); + result.put(FIELD_RUNNER, WorkflowContextCatalog::getRunnerItems); + result.put(FIELD_STRATEGY, WorkflowContextCatalog::getStrategyItems); + result.put(FIELD_DEFAULT, WorkflowContextCatalog::getCaretBracketItems); return result; } @@ -150,7 +143,7 @@ private static Map getGitHubEnvs() { } private static Map loadGeneratedItems(final String resourcePath) { - try (InputStream stream = GitHubWorkflowConfig.class.getResourceAsStream(resourcePath)) { + try (InputStream stream = WorkflowContextCatalog.class.getResourceAsStream(resourcePath)) { if (stream == null) { return Map.of(); } @@ -177,6 +170,6 @@ private static Map readGeneratedItems(final InputStream stream) return Collections.unmodifiableMap(result); } - private GitHubWorkflowConfig() { + private WorkflowContextCatalog() { } } diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementHelper.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowPsi.java similarity index 92% rename from src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementHelper.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowPsi.java index a75f2ec..d12285a 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/PsiElementHelper.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowPsi.java @@ -1,4 +1,6 @@ -package com.github.yunabraska.githubworkflow.helper; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.github.yunabraska.githubworkflow.model.SimpleElement; import com.intellij.openapi.application.ApplicationManager; @@ -8,7 +10,6 @@ import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.impl.source.tree.LeafPsiElement; -import com.github.yunabraska.githubworkflow.services.GitHubWorkflowBundle; import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.psi.YAMLAlias; import org.jetbrains.yaml.psi.YAMLAnchor; @@ -37,19 +38,19 @@ import java.util.regex.Matcher; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_JOBS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_STEPS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.PATTERN_GITHUB_ENV; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.PATTERN_GITHUB_ENV_MULTILINE; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.PATTERN_GITHUB_OUTPUT; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.PATTERN_GITHUB_OUTPUT_MULTILINE; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.PATTERN_GITHUB_OUTPUT_TEE; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_JOBS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_STEPS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.PATTERN_GITHUB_ENV; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.PATTERN_GITHUB_ENV_MULTILINE; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.PATTERN_GITHUB_OUTPUT; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.PATTERN_GITHUB_OUTPUT_MULTILINE; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.PATTERN_GITHUB_OUTPUT_TEE; import static java.util.Collections.unmodifiableList; import static java.util.Optional.ofNullable; -public class PsiElementHelper { +public class WorkflowPsi { - private PsiElementHelper() { + private WorkflowPsi() { // static helper class } @@ -58,19 +59,19 @@ public static Optional getParentJob(final PsiElement psiElement) { } public static List parseEnvVariables(final LeafPsiElement element) { - return element == null ? Collections.emptyList() : parseVariables(element, PsiElementHelper::toGithubEnvs); + return element == null ? Collections.emptyList() : parseVariables(element, WorkflowPsi::toGithubEnvs); } public static List parseOutputVariables(final LeafPsiElement element) { - return element == null ? Collections.emptyList() : parseVariables(element, PsiElementHelper::toGithubOutputs); + return element == null ? Collections.emptyList() : parseVariables(element, WorkflowPsi::toGithubOutputs); } public static List parseEnvVariables(final PsiElement psiElement) { - return psiElement == null ? Collections.emptyList() : parseVariables(psiElement, PsiElementHelper::toGithubEnvs); + return psiElement == null ? Collections.emptyList() : parseVariables(psiElement, WorkflowPsi::toGithubEnvs); } public static List parseOutputVariables(final PsiElement psiElement) { - return psiElement == null ? Collections.emptyList() : parseVariables(psiElement, PsiElementHelper::toGithubOutputs); + return psiElement == null ? Collections.emptyList() : parseVariables(psiElement, WorkflowPsi::toGithubOutputs); } public static Optional getChild(final PsiElement psiElement, final Class clazz) { @@ -103,11 +104,11 @@ public static List getChildren(final PsiElement psiElement) { } public static Optional getText(final PsiElement psiElement) { - return getTextElements(psiElement).stream().map(PsiElement::getText).map(PsiElementHelper::removeQuotes).filter(PsiElementHelper::hasText).findFirst(); + return getTextElements(psiElement).stream().map(PsiElement::getText).map(WorkflowPsi::removeQuotes).filter(WorkflowPsi::hasText).findFirst(); } public static Optional getText(final PsiElement psiElement, final String key) { - return getChild(psiElement, key).flatMap(PsiElementHelper::getText); + return getChild(psiElement, key).flatMap(WorkflowPsi::getText); } @@ -229,7 +230,7 @@ private static String normalizeAnchorName(final String name) { public static Optional getChild(final PsiElement psiElement, final String childKey) { return psiElement == null || childKey == null ? Optional.empty() : Optional.of(psiElement) - .map(PsiElementHelper::getChildren) + .map(WorkflowPsi::getChildren) .flatMap(children -> children.stream() .filter(Objects::nonNull) .filter(child -> childKey.equals(child.getKeyText())) @@ -254,7 +255,7 @@ public static Optional getParent(final PsiElement psiElement, fina public static Optional getParent(final PsiElement psiElement, final Predicate filter) { return psiElement == null || filter == null ? Optional.empty() : Optional.of(psiElement) - .flatMap(PsiElementHelper::toYAMLKeyValue) + .flatMap(WorkflowPsi::toYAMLKeyValue) .filter(filter) .or(() -> Optional.of(psiElement) .map(PsiElement::getParent) @@ -289,14 +290,14 @@ public static String getDescription(final PsiElement psiElement, final boolean r } public static Optional toPath(final VirtualFile virtualFile) { - return ofNullable(virtualFile).map(VirtualFile::getPath).flatMap(PsiElementHelper::toPath); + return ofNullable(virtualFile).map(VirtualFile::getPath).flatMap(WorkflowPsi::toPath); } public static Optional toPath(final String path) { try { return ofNullable(path) .map(String::trim) - .filter(PsiElementHelper::looksLikePathText) + .filter(WorkflowPsi::looksLikePathText) .map(Paths::get) .filter(p -> Files.exists(p) || ApplicationManager.getApplication().isUnitTestMode()); } catch (final Exception ignored) { diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferences.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferences.java new file mode 100644 index 0000000..fa7d5cc --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferences.java @@ -0,0 +1,538 @@ +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowPsi; +import com.github.yunabraska.githubworkflow.model.GitHubAction; +import com.github.yunabraska.githubworkflow.model.SimpleElement; +import com.github.yunabraska.githubworkflow.model.VariableReferenceResolver; +import com.intellij.openapi.util.Key; +import com.intellij.openapi.util.TextRange; +import com.intellij.patterns.PlatformPatterns; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiReference; +import com.intellij.psi.PsiReferenceContributor; +import com.intellij.psi.PsiReferenceProvider; +import com.intellij.psi.PsiReferenceRegistrar; +import com.intellij.util.ProcessingContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.yaml.psi.YAMLKeyValue; +import org.jetbrains.yaml.psi.YAMLSequenceItem; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ENVS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_GITEA; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_GITHUB; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ID; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_INPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_JOB; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_JOBS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_MATRIX; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_NEEDS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ON; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_OUTPUTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_PORTS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUN; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUNNER; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SECRETS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_SERVICES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_STEPS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_STRATEGY; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_USES; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_VARS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowYaml.getWorkflowFile; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParent; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getTextElements; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.removeQuotes; +import static com.github.yunabraska.githubworkflow.syntax.Action.referenceGithubAction; +import static com.github.yunabraska.githubworkflow.syntax.Inputs.listInputsRaw; +import static com.github.yunabraska.githubworkflow.syntax.JobContext.getService; +import static com.github.yunabraska.githubworkflow.syntax.Jobs.listAllJobs; +import static com.github.yunabraska.githubworkflow.syntax.Needs.getJobNeed; +import static com.github.yunabraska.githubworkflow.syntax.Needs.referenceNeeds; +import static com.github.yunabraska.githubworkflow.syntax.Steps.listSteps; +import static java.util.Optional.ofNullable; + +public class WorkflowReferences { + + public static final Key ACTION_KEY = new Key<>("ACTION_KEY"); + + public static class Contributor extends PsiReferenceContributor { + + @Override + public void registerReferenceProviders(@NotNull final PsiReferenceRegistrar registrar) { + registrar.registerReferenceProvider( + PlatformPatterns.psiElement(PsiElement.class), + new PsiReferenceProvider() { + @NotNull + @Override + public PsiReference @NotNull [] getReferencesByElement( + @NotNull final PsiElement psiElement, + @NotNull final ProcessingContext context + ) { + return getWorkflowFile(psiElement).isEmpty() ? PsiReference.EMPTY_ARRAY : textElement(psiElement) + .flatMap(element -> { + final String text = removeQuotes(element.getText().replace("IntellijIdeaRulezzz ", "").replace("IntellijIdeaRulezzz", "")); + return referenceGithubAction(element) + .or(() -> referenceNeeds(element, text)) + .or(() -> referenceVariables(element)); + } + ) + .orElse(PsiReference.EMPTY_ARRAY); + } + } + ); + } + } + + public record Target(String kind, SimpleElement source, SimpleElement segment, PsiElement target) { + } + + public static List toSimpleElements(final PsiElement element) { + if (getParent(element, FIELD_RUN).isPresent()) { + return toSimpleElementsInExpressions(element); + } + final List result = new ArrayList<>(); + final String text = element.getText(); + int lineStart = 0; + while (lineStart <= text.length()) { + int lineEnd = text.indexOf('\n', lineStart); + if (lineEnd < 0) { + lineEnd = text.length(); + } + final String line = text.substring(lineStart, lineEnd); + if (!line.isBlank() && !line.trim().startsWith("#")) { + final int currentLineStart = lineStart; + findDottedExpressions(line).stream() + .map(expression -> new SimpleElement( + expression.text(), + new TextRange( + currentLineStart + expression.range().getStartOffset(), + currentLineStart + expression.range().getEndOffset() + ) + )) + .forEach(result::add); + } + if (lineEnd == text.length()) { + break; + } + lineStart = lineEnd + 1; + } + return result; + } + + private static Optional textElement(final PsiElement psiElement) { + PsiElement current = psiElement; + while (current != null && current.getParent() != current) { + if (WorkflowPsi.isTextElement(current)) { + return Optional.of(current); + } + current = current.getParent(); + } + return Optional.empty(); + } + + private static Optional referenceVariables(final PsiElement psiElement) { + final PsiReference[] references = WorkflowReferences.resolve(psiElement).stream() + .map(target -> new VariableReferenceResolver( + psiElement, + new TextRange(target.segment().startIndexOffset(), target.segment().endIndexOffset()), + target.target() + )) + .toArray(PsiReference[]::new); + return references.length == 0 ? Optional.empty() : Optional.of(references); + } + + public static SimpleElement[] splitToElements(final SimpleElement simpleElement) { + final List result = new ArrayList<>(); + final AtomicInteger index = new AtomicInteger(0); + while (index.get() < simpleElement.text().length()) { + if (isIdentifierChar(simpleElement.text().charAt(index.get()))) { + result.add(readIdentifier(simpleElement, index)); + } else { + index.incrementAndGet(); + } + } + return result.toArray(SimpleElement[]::new); + } + + public static List resolve(final PsiElement psiElement) { + return toSimpleElements(psiElement).stream() + .flatMap(source -> resolveSource(psiElement, source).stream()) + .toList(); + } + + public static List resolveAt(final PsiElement psiElement, final int offsetInElement) { + return resolve(psiElement).stream() + .filter(target -> contains(target.segment(), offsetInElement)) + .toList(); + } + + public static Optional segmentAt(final PsiElement psiElement, final int offsetInElement) { + return toSimpleElements(psiElement).stream() + .filter(source -> contains(source, offsetInElement)) + .flatMap(source -> Stream.of(splitToElements(source))) + .filter(segment -> contains(segment, offsetInElement)) + .findFirst(); + } + + private static boolean contains(final SimpleElement segment, final int offsetInElement) { + return segment.startIndexOffset() - 1 <= offsetInElement && offsetInElement <= segment.endIndexOffset(); + } + + private static List findDottedExpressions(final String text) { + final List elements = new ArrayList<>(); + int index = 0; + while (index < text.length()) { + if (!isContextStart(text, index)) { + index++; + continue; + } + final int start = index; + boolean hasSeparator = false; + index = readIdentifierEnd(text, index); + while (index < text.length()) { + final char current = text.charAt(index); + if (current == '.') { + hasSeparator = true; + index++; + index = readIdentifierEnd(text, index); + } else if (current == '[') { + final int closingBracket = findClosingBracket(text, index); + if (closingBracket < 0) { + break; + } + hasSeparator = true; + index = closingBracket + 1; + } else { + break; + } + } + if (hasSeparator && start < index) { + elements.add(new SimpleElement(text.substring(start, index), new TextRange(start, index))); + } + } + return elements; + } + + private static List toSimpleElementsInExpressions(final PsiElement element) { + final List result = new ArrayList<>(); + final String text = element.getText(); + int index = 0; + while (index < text.length()) { + final int expressionStart = text.indexOf("${{", index); + if (expressionStart < 0) { + break; + } + final int bodyStart = expressionStart + 3; + final int expressionEnd = text.indexOf("}}", bodyStart); + if (expressionEnd < 0) { + break; + } + final String body = text.substring(bodyStart, expressionEnd); + findDottedExpressions(body).stream() + .map(expression -> new SimpleElement( + expression.text(), + new TextRange( + bodyStart + expression.range().getStartOffset(), + bodyStart + expression.range().getEndOffset() + ) + )) + .forEach(result::add); + index = expressionEnd + 2; + } + return result; + } + + private static SimpleElement readIdentifier(final SimpleElement simpleElement, final AtomicInteger index) { + final int start = index.get(); + index.set(readIdentifierEnd(simpleElement.text(), start)); + return new SimpleElement( + simpleElement.text().substring(start, index.get()), + new TextRange(simpleElement.range().getStartOffset() + start, simpleElement.range().getStartOffset() + index.get()) + ); + } + + private static int readIdentifierEnd(final String text, final int start) { + int index = start; + while (index < text.length() && isIdentifierChar(text.charAt(index))) { + index++; + } + return index; + } + + private static int findClosingBracket(final String text, final int start) { + int index = start + 1; + while (index < text.length()) { + if (text.charAt(index) == ']') { + return index; + } + index++; + } + return -1; + } + + private static boolean isContextStart(final String text, final int start) { + return List.of(FIELD_INPUTS, FIELD_SECRETS, FIELD_ENVS, FIELD_GITHUB, FIELD_GITEA, FIELD_JOB, FIELD_RUNNER, FIELD_MATRIX, FIELD_STRATEGY, FIELD_STEPS, FIELD_JOBS, FIELD_NEEDS, FIELD_VARS) + .stream() + .anyMatch(context -> text.startsWith(context, start) && hasContextSeparator(text, start + context.length())); + } + + private static boolean hasContextSeparator(final String text, final int index) { + return index < text.length() && (text.charAt(index) == '.' || text.charAt(index) == '['); + } + + public static boolean isIdentifierChar(final char character) { + return Character.isLetterOrDigit(character) || character == '_' || character == '-'; + } + + private static List resolveSource(final PsiElement psiElement, final SimpleElement source) { + final SimpleElement[] parts = splitToElements(source); + if (parts.length < 2) { + return List.of(); + } + final List result = new ArrayList<>(); + switch (parts[0].text()) { + case FIELD_INPUTS -> resolveInput(psiElement, source, parts[1]).ifPresent(result::add); + case FIELD_SECRETS -> resolveSecret(psiElement, source, parts[1]).ifPresent(result::add); + case FIELD_ENVS -> resolveEnv(psiElement, source, parts[1]).ifPresent(result::add); + case FIELD_MATRIX -> resolveMatrix(psiElement, source, parts[1]).ifPresent(result::add); + case FIELD_JOB -> resolveJobContext(psiElement, source, parts).ifPresent(result::add); + case FIELD_STEPS -> { + resolveStep(psiElement, source, parts[1]).ifPresent(result::add); + resolveStepOutput(psiElement, source, parts).ifPresent(result::add); + } + case FIELD_NEEDS -> { + resolveNeed(psiElement, source, parts[1]).ifPresent(result::add); + resolveNeedOutput(psiElement, source, parts).ifPresent(result::add); + } + case FIELD_JOBS -> { + resolveJob(psiElement, source, parts[1]).ifPresent(result::add); + resolveJobOutput(psiElement, source, parts).ifPresent(result::add); + } + default -> { + // Built-in contexts without a local declaration stay validated by highlighters, but are not clickable. + } + } + return result; + } + + private static Optional resolveInput( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement inputId + ) { + return listInputsRaw(psiElement).stream() + .filter(input -> inputId.text().equals(input.getKeyText())) + .findFirst() + .map(input -> new Target("input", source, inputId, input)); + } + + private static Optional resolveSecret( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement secretId + ) { + return getChild(psiElement.getContainingFile(), FIELD_ON) + .stream() + .flatMap(on -> WorkflowPsi.getAllElements(on, FIELD_SECRETS).stream()) + .flatMap(secrets -> WorkflowPsi.getChildren(secrets).stream()) + .filter(secret -> secretId.text().equals(secret.getKeyText())) + .findFirst() + .map(secret -> new Target("secret", source, secretId, secret)); + } + + private static Optional resolveEnv( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement envId + ) { + return Stream.of(stepEnv(psiElement, envId), jobEnv(psiElement, envId), workflowEnv(psiElement, envId)) + .flatMap(Optional::stream) + .findFirst() + .map(env -> new Target("env", source, envId, env)); + } + + private static Optional stepEnv(final PsiElement psiElement, final SimpleElement envId) { + return WorkflowPsi.getParentStep(psiElement) + .flatMap(step -> getChild(step, FIELD_ENVS)) + .flatMap(env -> childByKey(env, envId.text())); + } + + private static Optional jobEnv(final PsiElement psiElement, final SimpleElement envId) { + return WorkflowPsi.getParentJob(psiElement) + .flatMap(job -> getChild(job, FIELD_ENVS)) + .flatMap(env -> childByKey(env, envId.text())); + } + + private static Optional workflowEnv(final PsiElement psiElement, final SimpleElement envId) { + return getChild(psiElement.getContainingFile(), FIELD_ENVS) + .flatMap(env -> childByKey(env, envId.text())); + } + + private static Optional resolveMatrix( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement matrixId + ) { + return WorkflowPsi.getParentJob(psiElement) + .flatMap(job -> getChild(job, FIELD_STRATEGY)) + .flatMap(strategy -> getChild(strategy, FIELD_MATRIX)) + .flatMap(matrix -> matrixProperty(matrix, matrixId.text())) + .map(matrix -> new Target("matrix", source, matrixId, matrix)); + } + + private static Optional resolveJobContext( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement[] parts + ) { + if (parts.length >= 3 && FIELD_SERVICES.equals(parts[1].text())) { + if (parts.length >= 5 && FIELD_PORTS.equals(parts[3].text())) { + return getService(psiElement, parts[2].text()) + .flatMap(service -> getChild(service, FIELD_PORTS)) + .map(ports -> new Target("service-port", source, parts[4], ports)); + } + return getService(psiElement, parts[2].text()) + .map(service -> new Target("service", source, parts[2], service)); + } + if (parts.length >= 3 && "container".equals(parts[1].text())) { + return WorkflowPsi.getParentJob(psiElement) + .flatMap(job -> getChild(job, "container")) + .map(container -> new Target("container", source, parts[2], container)); + } + return Optional.empty(); + } + + private static Optional matrixProperty(final YAMLKeyValue matrix, final String key) { + return Stream.concat( + WorkflowPsi.getChildren(matrix).stream() + .filter(WorkflowReferences::isDirectMatrixProperty), + getChild(matrix, "include") + .stream() + .flatMap(include -> WorkflowPsi.getChildren(include, YAMLSequenceItem.class).stream()) + .flatMap(item -> WorkflowPsi.getChildren(item).stream()) + ) + .filter(property -> key.equals(property.getKeyText())) + .findFirst(); + } + + private static boolean isDirectMatrixProperty(final YAMLKeyValue keyValue) { + final String key = keyValue.getKeyText(); + return !"include".equals(key) && !"exclude".equals(key); + } + + private static Optional resolveStep( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement stepId + ) { + return listSteps(psiElement).stream() + .map(step -> getChild(step, FIELD_ID).orElse(null)) + .filter(Objects::nonNull) + .filter(id -> getText(id).filter(stepId.text()::equals).isPresent()) + .findFirst() + .map(step -> new Target("step", source, stepId, step)); + } + + private static Optional resolveStepOutput( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement[] parts + ) { + if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { + return Optional.empty(); + } + return listSteps(psiElement).stream() + .filter(step -> getText(step, FIELD_ID).filter(parts[1].text()::equals).isPresent()) + .findFirst() + .flatMap(step -> stepOutputTarget(step, parts[3].text())) + .map(output -> new Target("step-output", source, parts[3], output)); + } + + private static Optional stepOutputTarget(final YAMLSequenceItem step, final String outputId) { + return getChild(step, FIELD_RUN) + .filter(run -> WorkflowPsi.parseOutputVariables(run).stream().anyMatch(output -> outputId.equals(output.key()))) + .map(PsiElement.class::cast) + .or(() -> getChild(step, FIELD_USES) + .filter(uses -> com.github.yunabraska.githubworkflow.syntax.Action.listActionsOutputs(step).stream() + .anyMatch(output -> outputId.equals(output.key()))) + .map(PsiElement.class::cast)); + } + + private static Optional resolveNeed( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement needId + ) { + return getJobNeed(psiElement).stream() + .flatMap(need -> getTextElements(need).stream()) + .filter(need -> needId.text().equals(removeQuotes(need.getText()))) + .findFirst() + .map(need -> new Target("need", source, needId, need)); + } + + private static Optional resolveNeedOutput( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement[] parts + ) { + if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { + return Optional.empty(); + } + return jobById(psiElement, parts[1].text()) + .flatMap(job -> jobOutput(job, parts[3].text())) + .map(output -> new Target("need-output", source, parts[3], output)); + } + + private static Optional resolveJob( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement jobId + ) { + return jobById(psiElement, jobId.text()) + .map(job -> new Target("job", source, jobId, job)); + } + + private static Optional resolveJobOutput( + final PsiElement psiElement, + final SimpleElement source, + final SimpleElement[] parts + ) { + if (parts.length < 4 || !FIELD_OUTPUTS.equals(parts[2].text())) { + return Optional.empty(); + } + return jobById(psiElement, parts[1].text()) + .flatMap(job -> jobOutput(job, parts[3].text())) + .map(output -> new Target("job-output", source, parts[3], output)); + } + + private static Optional jobById(final PsiElement psiElement, final String jobId) { + return listAllJobs(psiElement).stream() + .filter(job -> jobId.equals(job.getKeyText())) + .findFirst(); + } + + private static Optional jobOutput(final YAMLKeyValue job, final String outputId) { + return getChild(job, FIELD_OUTPUTS) + .flatMap(outputs -> childByKey(outputs, outputId)); + } + + private static Optional childByKey(final PsiElement parent, final String key) { + return ofNullable(parent) + .stream() + .flatMap(element -> WorkflowPsi.getChildren(element).stream()) + .filter(child -> key.equals(child.getKeyText())) + .findFirst(); + } + + private WorkflowReferences() { + // static helper class + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java new file mode 100644 index 0000000..71f5242 --- /dev/null +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntax.java @@ -0,0 +1,603 @@ +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowYaml; +import com.github.yunabraska.githubworkflow.model.GitHubSchemaProvider; +import com.intellij.icons.AllIcons; +import com.intellij.ide.IconProvider; +import com.intellij.lang.Language; +import com.intellij.lang.injection.MultiHostInjector; +import com.intellij.lang.injection.MultiHostRegistrar; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.IconLoader; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.jetbrains.jsonSchema.extension.JsonSchemaFileProvider; +import com.jetbrains.jsonSchema.extension.JsonSchemaProviderFactory; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.yaml.psi.YAMLKeyValue; +import org.jetbrains.yaml.psi.YAMLScalar; +import org.jetbrains.yaml.psi.impl.YAMLScalarImpl; + +import javax.swing.Icon; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.*; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getChild; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentJob; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getParentStep; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowPsi.getText; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.isChildOf; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathEndsWith; +import static com.github.yunabraska.githubworkflow.git.WorkflowLocation.pathMatches; + +/** + * GitHub Actions workflow syntax completion tables from the public workflow syntax reference. + */ +public class WorkflowSyntax { + + private static final String WORKFLOW_SYNTAX_RESOURCE = "/github-docs/workflow-syntax.tsv"; + private static final List SCHEMA_FILE_PROVIDERS = Stream.of( + new GitHubSchemaProvider("dependabot-2.0", "Dependabot [Auto]", WorkflowYaml::isDependabotFile), + new GitHubSchemaProvider("github-action", "GitHub Action [Auto]", WorkflowYaml::isActionFile), + new GitHubSchemaProvider("github-funding", "GitHub Funding [Auto]", WorkflowYaml::isFoundingFile), + new GitHubSchemaProvider("github-workflow", "GitHub Workflow [Auto]", WorkflowYaml::isWorkflowFile), + new GitHubSchemaProvider("github-discussion", "GitHub Discussion [Auto]", WorkflowYaml::isDiscussionFile), + new GitHubSchemaProvider("github-issue-forms", "GitHub Issue Forms [Auto]", WorkflowYaml::isIssueForms), + new GitHubSchemaProvider("github-issue-config", "GitHub Workflow Issue Template configuration [Auto]", WorkflowYaml::isIssueConfigFile), + new GitHubSchemaProvider("github-workflow-template-properties", "GitHub Workflow Template Properties [Auto]", WorkflowYaml::isWorkflowTemplatePropertiesFile) + ) + .distinct() + .toList(); + private static final List KEY_RULES = List.of( + rule((path, completion) -> path.isEmpty(), "top", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_ON), "event", "inspection.workflow.syntax.unknownEventKey"), + rule((path, completion) -> pathMatches(path, FIELD_ON, "workflow_dispatch"), "trigger.workflow_dispatch", "inspection.workflow.syntax.unknownTriggerKey"), + rule((path, completion) -> pathMatches(path, FIELD_ON, "workflow_call"), "trigger.workflow_call", "inspection.workflow.syntax.unknownTriggerKey"), + rule( + (path, completion) -> isChildOf(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) + || isChildOf(path, FIELD_ON, "workflow_call", FIELD_INPUTS), + ignored -> workflowInputPropertyKeys(), + "inspection.workflow.syntax.unknownTriggerKey" + ), + rule( + (path, completion) -> isChildOf(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS), + ignored -> workflowOutputPropertyKeys(), + "inspection.workflow.syntax.unknownTriggerKey" + ), + rule( + (path, completion) -> isChildOf(path, FIELD_ON, "workflow_call", FIELD_SECRETS), + ignored -> workflowSecretPropertyKeys(), + "inspection.workflow.syntax.unknownTriggerKey" + ), + rule( + (path, completion) -> pathMatches(path, FIELD_ON, "*"), + path -> eventFilterKeysFor(path.get(path.size() - 1)), + "inspection.workflow.syntax.unknownTriggerFilter" + ), + rule((path, completion) -> pathEndsWith(path, "permissions"), "permission", "inspection.workflow.syntax.unknownPermission"), + rule( + (path, completion) -> pathMatches(path, "defaults", FIELD_RUN) + || pathMatches(path, FIELD_JOBS, "*", "defaults", FIELD_RUN), + "defaultsRun", + "inspection.workflow.syntax.unknownTopLevelKey" + ), + rule( + (path, completion) -> pathMatches(path, "concurrency") + || pathMatches(path, FIELD_JOBS, "*", "concurrency"), + "concurrency", + "inspection.workflow.syntax.unknownTopLevelKey" + ), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", "environment"), "environment", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*"), "job", "inspection.workflow.syntax.unknownJobKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", FIELD_STRATEGY), "strategy", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> completion && pathMatches(path, FIELD_JOBS, "*", FIELD_STRATEGY, FIELD_MATRIX), "matrix", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", "container"), "container", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", "container", "credentials"), "credentials", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*"), "service", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", FIELD_SERVICES, "*", "credentials"), "credentials", "inspection.workflow.syntax.unknownTopLevelKey"), + rule((path, completion) -> pathMatches(path, FIELD_JOBS, "*", FIELD_STEPS), "step", "inspection.workflow.syntax.unknownStepKey") + ); + + private WorkflowSyntax() { + } + + public static class FileIcon extends IconProvider { + // IconLoader automatically resolves /icons/gitea_dark.svg in dark themes. + private static final Icon GITEA_ICON = IconLoader.getIcon("/icons/gitea.svg", FileIcon.class); + private static final String GITEA_WORKFLOW_HOME = ".gitea"; + + @Nullable + @Override + @SuppressWarnings("java:S2637") + public Icon getIcon(@NotNull final PsiElement element, final int flags) { + return Optional.of(element) + .filter(PsiFile.class::isInstance) + .map(PsiFile.class::cast) + .map(PsiFile::getVirtualFile) + .flatMap(virtualFile -> SCHEMA_FILE_PROVIDERS.stream() + .filter(GitHubSchemaProvider.class::isInstance) + .map(GitHubSchemaProvider.class::cast) + .filter(schemaProvider -> schemaProvider.isAvailable(virtualFile)) + .map(schema -> iconFor(virtualFile)) + .findFirst() + ) + .orElse(null); + } + + private static Icon iconFor(final VirtualFile virtualFile) { + return isGiteaWorkflowFile(virtualFile) ? GITEA_ICON : AllIcons.Vcs.Vendors.Github; + } + + private static boolean isGiteaWorkflowFile(final VirtualFile virtualFile) { + return WorkflowPsi.toPath(virtualFile) + .filter(WorkflowYaml::isWorkflowFile) + .filter(FileIcon::isGiteaWorkflowPath) + .isPresent(); + } + + private static boolean isGiteaWorkflowPath(final Path path) { + return path.getName(path.getNameCount() - 3).toString().equalsIgnoreCase(GITEA_WORKFLOW_HOME); + } + } + + public static class Schema implements JsonSchemaProviderFactory { + + @NotNull + @Override + public List getProviders(@NotNull final Project project) { + return SCHEMA_FILE_PROVIDERS; + } + } + + public static class RunLanguageInjector implements MultiHostInjector { + + @Override + public void getLanguagesToInject(@NotNull final MultiHostRegistrar registrar, @NotNull final PsiElement context) { + if (!(context instanceof YAMLScalar scalar) || !isRunScalar(scalar)) { + return; + } + languageForShell(scalar) + .ifPresent(language -> inject(registrar, scalar, language)); + } + + @Override + public @NotNull List> elementsToInjectIn() { + return List.of(YAMLScalar.class); + } + + private static boolean isRunScalar(final YAMLScalar scalar) { + return scalar.getParent() instanceof YAMLKeyValue keyValue && FIELD_RUN.equals(keyValue.getKeyText()); + } + + private static Optional languageForShell(final YAMLScalar scalar) { + return shellFor(scalar) + .map(RunLanguageInjector::languageId) + .flatMap(id -> Optional.ofNullable(Language.findLanguageByID(id))); + } + + private static Optional shellFor(final YAMLScalar scalar) { + return getParentStep(scalar) + .flatMap(step -> getText(step, "shell")) + .or(() -> getParentJob(scalar) + .flatMap(job -> getChild(job, "defaults")) + .flatMap(defaults -> getChild(defaults, FIELD_RUN)) + .flatMap(run -> getText(run, "shell"))) + .or(() -> getChild(scalar.getContainingFile(), "defaults") + .flatMap(defaults -> getChild(defaults, FIELD_RUN)) + .flatMap(run -> getText(run, "shell"))) + .or(() -> Optional.of("bash")); + } + + private static String languageId(final String shell) { + final String normalized = shell.toLowerCase(Locale.ROOT).trim(); + if (normalized.contains("pwsh") || normalized.contains("powershell")) { + return "PowerShell"; + } + if (normalized.contains("python")) { + return "Python"; + } + if (normalized.contains("node") || normalized.contains("javascript") || normalized.equals("js")) { + return "JavaScript"; + } + if (normalized.contains("ruby")) { + return "Ruby"; + } + if (normalized.contains("perl")) { + return "Perl"; + } + return "Shell Script"; + } + + private static void inject(final MultiHostRegistrar registrar, final YAMLScalar scalar, final Language language) { + final List ranges = contentRanges(scalar); + if (ranges.isEmpty()) { + return; + } + registrar.startInjecting(language); + ranges.forEach(range -> registrar.addPlace(null, null, scalar, range)); + registrar.doneInjecting(); + } + + private static List contentRanges(final YAMLScalar scalar) { + final List ranges = scalar instanceof YAMLScalarImpl scalarImpl + ? scalarImpl.getContentRanges() + : fallbackContentRanges(scalar); + final List withoutExpressions = ranges.stream() + .flatMap(range -> excludeWorkflowExpressions(scalar.getText(), range).stream()) + .toList(); + return subtractRanges(withoutExpressions, hereDocBodyRanges(scalar.getText(), new TextRange(0, scalar.getTextLength()))).stream() + .filter(range -> range.getStartOffset() < range.getEndOffset()) + .toList(); + } + + private static List fallbackContentRanges(final YAMLScalar scalar) { + final int length = scalar.getTextLength(); + return length == 0 ? List.of() : List.of(new TextRange(0, length)); + } + + private static List excludeWorkflowExpressions(final String text, final TextRange range) { + final java.util.ArrayList result = new java.util.ArrayList<>(); + int start = range.getStartOffset(); + while (start < range.getEndOffset()) { + final int expressionStart = text.indexOf("${{", start); + if (expressionStart < 0 || expressionStart >= range.getEndOffset()) { + result.add(new TextRange(start, range.getEndOffset())); + break; + } + if (start < expressionStart) { + result.add(new TextRange(start, expressionStart)); + } + final int expressionEnd = text.indexOf("}}", expressionStart + 3); + start = expressionEnd < 0 ? range.getEndOffset() : Math.min(expressionEnd + 2, range.getEndOffset()); + } + return result; + } + + private static List hereDocBodyRanges(final String text, final TextRange range) { + final java.util.ArrayList result = new java.util.ArrayList<>(); + String delimiter = ""; + int bodyStart = -1; + int lineStart = range.getStartOffset(); + while (lineStart < range.getEndOffset()) { + final int newline = text.indexOf('\n', lineStart); + final int lineEnd = newline < 0 ? range.getEndOffset() : Math.min(newline, range.getEndOffset()); + final String line = text.substring(lineStart, lineEnd); + if (delimiter.isBlank()) { + final Optional nextDelimiter = hereDocDelimiter(line); + if (nextDelimiter.isPresent()) { + delimiter = nextDelimiter.get(); + bodyStart = Math.min(lineEnd + 1, range.getEndOffset()); + } + } else if (line.trim().equals(delimiter)) { + if (bodyStart >= 0 && bodyStart < lineStart) { + result.add(new TextRange(bodyStart, lineStart)); + } + delimiter = ""; + bodyStart = -1; + } + if (newline < 0 || lineEnd >= range.getEndOffset()) { + break; + } + lineStart = lineEnd + 1; + } + if (!delimiter.isBlank() && bodyStart >= 0 && bodyStart < range.getEndOffset()) { + result.add(new TextRange(bodyStart, range.getEndOffset())); + } + return result; + } + + private static Optional hereDocDelimiter(final String line) { + char quote = 0; + for (int index = 0; index + 1 < line.length(); index++) { + final char current = line.charAt(index); + if (quote != 0) { + if (current == quote) { + quote = 0; + } + continue; + } + if (current == '\'' || current == '"') { + quote = current; + continue; + } + if (current == '<' && line.charAt(index + 1) == '<') { + int delimiterStart = index + 2; + if (delimiterStart < line.length() && line.charAt(delimiterStart) == '-') { + delimiterStart++; + } + while (delimiterStart < line.length() && Character.isWhitespace(line.charAt(delimiterStart))) { + delimiterStart++; + } + int delimiterEnd = delimiterStart; + while (delimiterEnd < line.length() && isDelimiterChar(line.charAt(delimiterEnd))) { + delimiterEnd++; + } + if (delimiterStart < delimiterEnd) { + return Optional.of(line.substring(delimiterStart, delimiterEnd)); + } + } + } + return Optional.empty(); + } + + private static boolean isDelimiterChar(final char character) { + return Character.isLetterOrDigit(character) || character == '_'; + } + + private static List subtractRanges(final List ranges, final List excludedRanges) { + List result = ranges; + for (final TextRange excludedRange : excludedRanges) { + result = result.stream() + .flatMap(range -> subtractRange(range, excludedRange).stream()) + .toList(); + } + return result; + } + + private static List subtractRange(final TextRange range, final TextRange excludedRange) { + if (!range.intersectsStrict(excludedRange)) { + return List.of(range); + } + final java.util.ArrayList result = new java.util.ArrayList<>(); + if (range.getStartOffset() < excludedRange.getStartOffset()) { + result.add(new TextRange(range.getStartOffset(), excludedRange.getStartOffset())); + } + if (excludedRange.getEndOffset() < range.getEndOffset()) { + result.add(new TextRange(excludedRange.getEndOffset(), range.getEndOffset())); + } + return result; + } + } + + static Map topLevelKeys() { + return table("top"); + } + + static Map eventKeys() { + return table("event"); + } + + static Map eventFilterKeys() { + return table("eventFilter"); + } + + public static Map eventFilterKeysFor(final String event) { + final Map result = table("eventFilter." + event); + return result.isEmpty() ? eventFilterKeys() : result; + } + + public static Optional> completionKeysForPath(final List path) { + return knownKeysForPath(path, true).map(KnownKeys::values); + } + + public static Optional validationKeysForPath(final List path) { + return knownKeysForPath(path, false); + } + + private static Optional knownKeysForPath(final List path, final boolean completion) { + if (pathEndsWith(path, FIELD_ON, "workflow_dispatch", FIELD_INPUTS) + || pathEndsWith(path, FIELD_ON, "workflow_call", FIELD_INPUTS) + || pathEndsWith(path, FIELD_ON, "workflow_call", FIELD_OUTPUTS) + || pathEndsWith(path, FIELD_ON, "workflow_call", FIELD_SECRETS)) { + return Optional.empty(); + } + return KEY_RULES.stream() + .flatMap(rule -> rule.known(path, completion).stream()) + .findFirst(); + } + + public static Map eventActivityTypesFor(final String event) { + return table("activity." + event); + } + + static Map permissionScopes() { + return table("permission"); + } + + static Map permissionValues() { + return table("permissionValue"); + } + + public static Map permissionValuesFor(final String permission) { + final Map result = table("permissionValue." + permission); + return result.isEmpty() ? permissionValues() : result; + } + + public static Map permissionShorthandValues() { + return table("permissionShorthand"); + } + + static Map jobKeys() { + return table("job"); + } + + static Map defaultsRunKeys() { + return table("defaultsRun"); + } + + static Map concurrencyKeys() { + return table("concurrency"); + } + + static Map environmentKeys() { + return table("environment"); + } + + static Map strategyKeys() { + return table("strategy"); + } + + static Map matrixKeys() { + return table("matrix"); + } + + static Map stepKeys() { + return table("step"); + } + + static Map containerKeys() { + return table("container"); + } + + static Map serviceKeys() { + return table("service"); + } + + static Map credentialsKeys() { + return table("credentials"); + } + + static Map workflowInputTypes() { + return table("inputType.workflow_dispatch"); + } + + static Map reusableWorkflowInputTypes() { + return table("inputType.workflow_call"); + } + + public static Map workflowInputTypesFor(final String trigger) { + return "workflow_call".equals(trigger) ? reusableWorkflowInputTypes() : workflowInputTypes(); + } + + static Map workflowDispatchTriggerKeys() { + return table("trigger.workflow_dispatch"); + } + + static Map workflowCallTriggerKeys() { + return table("trigger.workflow_call"); + } + + static Map workflowInputPropertyKeys() { + final Map result = new LinkedHashMap<>(); + result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); + result.put("type", GitHubWorkflowBundle.message("documentation.type", "string | boolean | choice | number | environment")); + result.put("required", GitHubWorkflowBundle.message("documentation.required", true)); + result.put("default", GitHubWorkflowBundle.message("documentation.default", "")); + result.put("options", GitHubWorkflowBundle.message("documentation.value.label")); + return java.util.Collections.unmodifiableMap(result); + } + + static Map workflowOutputPropertyKeys() { + final Map result = new LinkedHashMap<>(); + result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); + result.put("value", GitHubWorkflowBundle.message("documentation.value.label")); + return java.util.Collections.unmodifiableMap(result); + } + + static Map workflowSecretPropertyKeys() { + final Map result = new LinkedHashMap<>(); + result.put("description", GitHubWorkflowBundle.message("documentation.description.label")); + result.put("required", GitHubWorkflowBundle.message("documentation.required", true)); + return java.util.Collections.unmodifiableMap(result); + } + + public static Map booleanValues() { + return table("boolean"); + } + + public static Map runnerLabels() { + return table("runner"); + } + + private static Map table(final String group) { + return Tables.DATA.getOrDefault(group, Collections.emptyMap()); + } + + private static Map> loadTables() { + final Map> result = new LinkedHashMap<>(); + try (BufferedReader reader = syntaxReader()) { + String line = reader.readLine(); + int lineNumber = 1; + while (line != null) { + loadTableLine(result, line, lineNumber); + line = reader.readLine(); + lineNumber++; + } + } catch (final IOException exception) { + throw new IllegalStateException("Cannot read " + WORKFLOW_SYNTAX_RESOURCE, exception); + } + return immutableTables(result); + } + + private static BufferedReader syntaxReader() { + final InputStream stream = WorkflowSyntax.class.getResourceAsStream(WORKFLOW_SYNTAX_RESOURCE); + if (stream == null) { + throw new IllegalStateException("Missing " + WORKFLOW_SYNTAX_RESOURCE); + } + return new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)); + } + + private static void loadTableLine(final Map> result, final String rawLine, final int lineNumber) { + final String line = rawLine.strip(); + if (line.isBlank() || line.startsWith("#")) { + return; + } + final String[] parts = rawLine.split("\t", 3); + if (parts.length != 3 || parts[0].isBlank() || parts[1].isBlank() || parts[2].isBlank()) { + throw new IllegalStateException("Invalid " + WORKFLOW_SYNTAX_RESOURCE + " line " + lineNumber); + } + result.computeIfAbsent(parts[0], ignored -> new LinkedHashMap<>()) + .put(parts[1], GitHubWorkflowBundle.message(parts[2])); + } + + private static Map> immutableTables(final Map> source) { + final Map> tables = new LinkedHashMap<>(); + source.forEach((group, values) -> tables.put(group, Collections.unmodifiableMap(new LinkedHashMap<>(values)))); + return Collections.unmodifiableMap(tables); + } + + private static class Tables { + private static final Map> DATA = loadTables(); + + private Tables() { + } + } + + private static SyntaxRule rule(final PathPredicate predicate, final String table, final String messageKey) { + return rule(predicate, ignored -> table(table), messageKey); + } + + private static SyntaxRule rule(final PathPredicate predicate, final ValueProvider values, final String messageKey) { + return new SyntaxRule(predicate, values, messageKey); + } + + @FunctionalInterface + private interface PathPredicate { + boolean matches(List path, boolean completion); + } + + @FunctionalInterface + private interface ValueProvider { + Map values(List path); + } + + private record SyntaxRule(PathPredicate predicate, ValueProvider values, String messageKey) { + Optional known(final List path, final boolean completion) { + return predicate.matches(path, completion) + ? Optional.of(new KnownKeys(values.values(path), messageKey)) + : Optional.empty(); + } + } + + public record KnownKeys(Map values, String messageKey) { + } +} diff --git a/src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelper.java b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java similarity index 82% rename from src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelper.java rename to src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java index e68cec7..2062ef9 100644 --- a/src/main/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelper.java +++ b/src/main/java/com/github/yunabraska/githubworkflow/syntax/WorkflowYaml.java @@ -1,13 +1,17 @@ -package com.github.yunabraska.githubworkflow.helper; +package com.github.yunabraska.githubworkflow.syntax; import com.github.yunabraska.githubworkflow.model.NodeIcon; +import com.intellij.codeInsight.AutoPopupController; +import com.intellij.codeInsight.completion.InsertionContext; import com.intellij.codeInsight.completion.PrioritizedLookupElement; import com.intellij.codeInsight.lookup.LookupElement; import com.intellij.codeInsight.lookup.LookupElementBuilder; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Document; import com.intellij.psi.FileViewProvider; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; +import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.psi.YAMLScalar; import java.nio.file.Path; @@ -15,13 +19,12 @@ import java.util.List; import java.util.Optional; -import static com.github.yunabraska.githubworkflow.helper.AutoPopupInsertHandler.addSuffix; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_IF; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_IF; -public class GitHubWorkflowHelper { +public class WorkflowYaml { private static final String COMPLETION_DUMMY = "IntellijIdeaRulezzz"; - private GitHubWorkflowHelper() { + private WorkflowYaml() { // static helper } @@ -40,7 +43,7 @@ public static Optional getCaretBracketItem(final PsiElement position, final int cursorRel = Math.max(0, Math.min(adjustedCursorRel, wholeText.length())); final String offsetText = wholeText.substring(0, cursorRel); final int bracketStart = offsetText.lastIndexOf("${{"); - if (cursorRel > 2 && isInBrackets(offsetText, bracketStart) || PsiElementHelper.getParent(context, FIELD_IF).isPresent()) { + if (cursorRel > 2 && isInBrackets(offsetText, bracketStart) || WorkflowPsi.getParent(context, FIELD_IF).isPresent()) { return getCaretBracketItem(prefix, wholeText, cursorRel); } return Optional.empty(); @@ -55,7 +58,7 @@ private static PsiElement completionContextElement(final PsiElement position, fi && offset <= current.getTextRange().getEndOffset(); if (containsOffset && current.getText() != null && current.getText().contains(COMPLETION_DUMMY)) { fallback = current; - if (PsiElementHelper.isTextElement(current) || current instanceof YAMLScalar) { + if (WorkflowPsi.isTextElement(current) || current instanceof YAMLScalar) { return current; } } @@ -207,14 +210,59 @@ public static LookupElement toLookupElement(final NodeIcon icon, final char suff return PrioritizedLookupElement.withPriority(result, icon.ordinal() + 5d); } + private static void addSuffix(final InsertionContext ctx, final LookupElement item, final char suffix) { + if (suffix != Character.MIN_VALUE) { + final String key = item.getLookupString(); + final int startOffset = ctx.getStartOffset(); + final Document document = ctx.getDocument(); + final CharSequence documentChars = document.getCharsSequence(); + final int tailOffset = ctx.getTailOffset(); + final String insertText = suffixText(suffix, documentChars, tailOffset); + + document.replaceString(startOffset, suffixEndIndex(ctx, suffix, documentChars, tailOffset), key + insertText); + ctx.getEditor().getCaretModel().moveToOffset(startOffset + (key + insertText).length()); + + if (suffix == '.') { + AutoPopupController.getInstance(ctx.getProject()).scheduleAutoPopup(ctx.getEditor()); + } + } + } + + private static int suffixEndIndex(final InsertionContext ctx, final char suffix, final CharSequence documentChars, final int tailOffset) { + int result = tailOffset; + if (ctx.getCompletionChar() == '\t') { + while (result < documentChars.length() + && documentChars.charAt(result) != suffix + && !isLineBreak(documentChars.charAt(result)) + ) { + result++; + } + } + return result; + } + + private static boolean isLineBreak(final char c) { + return c == '\n' || c == '\r'; + } + + @NotNull + private static String suffixText(final char suffix, final CharSequence documentChars, final int tailOffset) { + final StringBuilder result = new StringBuilder().append(suffix); + final boolean isNextCharSpace = tailOffset < documentChars.length() && documentChars.charAt(tailOffset) == ' '; + if (suffix != '.' && !isNextCharSpace) { + result.append(' '); + } + return result.toString(); + } + public static Optional getWorkflowFile(final PsiElement psiElement) { return Optional.ofNullable(psiElement) .map(PsiElement::getContainingFile) .map(PsiFile::getOriginalFile) .map(PsiFile::getViewProvider) .map(FileViewProvider::getVirtualFile) - .flatMap(PsiElementHelper::toPath) - .filter(GitHubWorkflowHelper::isWorkflowPath); + .flatMap(WorkflowPsi::toPath) + .filter(WorkflowYaml::isWorkflowPath); } public static boolean isWorkflowPath(final Path path) { diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index df1483b..09e0fbb 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -17,50 +17,50 @@ + implementationClass="com.github.yunabraska.githubworkflow.entry.WorkflowCompletion"/> - + - - + implementationClass="com.github.yunabraska.githubworkflow.entry.WorkflowAnnotator"/> - + - - - + + + + implementationClass="com.github.yunabraska.githubworkflow.run.WorkflowRun$LineMarkerContributor"/> - - + + - - - - + + - + - + - + - + + class="com.github.yunabraska.githubworkflow.state.GitHubActionCache$RefreshAction"/> + class="com.github.yunabraska.githubworkflow.state.GitHubActionCache$RestoreWarningsAction"/> + class="com.github.yunabraska.githubworkflow.state.GitHubActionCache$ClearAction"/> diff --git a/src/main/resources/github-docs/workflow-syntax.tsv b/src/main/resources/github-docs/workflow-syntax.tsv new file mode 100644 index 0000000..f5eaa2c --- /dev/null +++ b/src/main/resources/github-docs/workflow-syntax.tsv @@ -0,0 +1,296 @@ +# group key bundle-key +top name completion.workflow.top.name +top run-name completion.workflow.top.run-name +top on completion.workflow.top.on +top permissions completion.workflow.top.permissions +top env completion.workflow.top.env +top defaults completion.workflow.top.defaults +top concurrency completion.workflow.top.concurrency +top jobs completion.workflow.top.jobs +event branch_protection_rule completion.workflow.event.branch_protection_rule +event check_run completion.workflow.event.check_run +event check_suite completion.workflow.event.check_suite +event create completion.workflow.event.create +event delete completion.workflow.event.delete +event deployment completion.workflow.event.deployment +event deployment_status completion.workflow.event.deployment_status +event discussion completion.workflow.event.discussion +event discussion_comment completion.workflow.event.discussion_comment +event fork completion.workflow.event.fork +event gollum completion.workflow.event.gollum +event image_version completion.workflow.event.image_version +event issue_comment completion.workflow.event.issue_comment +event issues completion.workflow.event.issues +event label completion.workflow.event.label +event merge_group completion.workflow.event.merge_group +event milestone completion.workflow.event.milestone +event page_build completion.workflow.event.page_build +event project completion.workflow.event.project +event project_card completion.workflow.event.project_card +event project_column completion.workflow.event.project_column +event public completion.workflow.event.public +event pull_request completion.workflow.event.pull_request +event pull_request_review completion.workflow.event.pull_request_review +event pull_request_review_comment completion.workflow.event.pull_request_review_comment +event pull_request_target completion.workflow.event.pull_request_target +event push completion.workflow.event.push +event registry_package completion.workflow.event.registry_package +event release completion.workflow.event.release +event repository_dispatch completion.workflow.event.repository_dispatch +event schedule completion.workflow.event.schedule +event status completion.workflow.event.status +event watch completion.workflow.event.watch +event workflow_call completion.workflow.event.workflow_call +event workflow_dispatch completion.workflow.event.workflow_dispatch +event workflow_run completion.workflow.event.workflow_run +eventFilter types completion.workflow.eventFilter.types +eventFilter branches completion.workflow.eventFilter.branches +eventFilter branches-ignore completion.workflow.eventFilter.branches-ignore +eventFilter tags completion.workflow.eventFilter.tags +eventFilter tags-ignore completion.workflow.eventFilter.tags-ignore +eventFilter paths completion.workflow.eventFilter.paths +eventFilter paths-ignore completion.workflow.eventFilter.paths-ignore +eventFilter workflows completion.workflow.eventFilter.workflows +eventFilter cron completion.workflow.eventFilter.cron +eventFilter.schedule cron completion.workflow.eventFilter.cron +eventFilter.workflow_run workflows completion.workflow.eventFilter.workflows +eventFilter.workflow_run types completion.workflow.eventFilter.types +eventFilter.workflow_run branches completion.workflow.eventFilter.branches +eventFilter.workflow_run branches-ignore completion.workflow.eventFilter.branches-ignore +eventFilter.push branches completion.workflow.eventFilter.branches +eventFilter.push branches-ignore completion.workflow.eventFilter.branches-ignore +eventFilter.push tags completion.workflow.eventFilter.tags +eventFilter.push tags-ignore completion.workflow.eventFilter.tags-ignore +eventFilter.push paths completion.workflow.eventFilter.paths +eventFilter.push paths-ignore completion.workflow.eventFilter.paths-ignore +eventFilter.pull_request types completion.workflow.eventFilter.types +eventFilter.pull_request branches completion.workflow.eventFilter.branches +eventFilter.pull_request branches-ignore completion.workflow.eventFilter.branches-ignore +eventFilter.pull_request paths completion.workflow.eventFilter.paths +eventFilter.pull_request paths-ignore completion.workflow.eventFilter.paths-ignore +eventFilter.pull_request_target types completion.workflow.eventFilter.types +eventFilter.pull_request_target branches completion.workflow.eventFilter.branches +eventFilter.pull_request_target branches-ignore completion.workflow.eventFilter.branches-ignore +eventFilter.pull_request_target paths completion.workflow.eventFilter.paths +eventFilter.pull_request_target paths-ignore completion.workflow.eventFilter.paths-ignore +activity.branch_protection_rule created completion.workflow.eventFilter.types +activity.branch_protection_rule deleted completion.workflow.eventFilter.types +activity.check_run created completion.workflow.eventFilter.types +activity.check_run rerequested completion.workflow.eventFilter.types +activity.check_run completed completion.workflow.eventFilter.types +activity.check_run requested_action completion.workflow.eventFilter.types +activity.check_suite completed completion.workflow.eventFilter.types +activity.discussion created completion.workflow.eventFilter.types +activity.discussion edited completion.workflow.eventFilter.types +activity.discussion deleted completion.workflow.eventFilter.types +activity.discussion transferred completion.workflow.eventFilter.types +activity.discussion pinned completion.workflow.eventFilter.types +activity.discussion unpinned completion.workflow.eventFilter.types +activity.discussion labeled completion.workflow.eventFilter.types +activity.discussion unlabeled completion.workflow.eventFilter.types +activity.discussion locked completion.workflow.eventFilter.types +activity.discussion unlocked completion.workflow.eventFilter.types +activity.discussion category_changed completion.workflow.eventFilter.types +activity.discussion answered completion.workflow.eventFilter.types +activity.discussion unanswered completion.workflow.eventFilter.types +activity.discussion_comment created completion.workflow.eventFilter.types +activity.discussion_comment edited completion.workflow.eventFilter.types +activity.discussion_comment deleted completion.workflow.eventFilter.types +activity.issue_comment created completion.workflow.eventFilter.types +activity.issue_comment edited completion.workflow.eventFilter.types +activity.issue_comment deleted completion.workflow.eventFilter.types +activity.pull_request_review_comment created completion.workflow.eventFilter.types +activity.pull_request_review_comment edited completion.workflow.eventFilter.types +activity.pull_request_review_comment deleted completion.workflow.eventFilter.types +activity.issues opened completion.workflow.eventFilter.types +activity.issues edited completion.workflow.eventFilter.types +activity.issues deleted completion.workflow.eventFilter.types +activity.issues transferred completion.workflow.eventFilter.types +activity.issues pinned completion.workflow.eventFilter.types +activity.issues unpinned completion.workflow.eventFilter.types +activity.issues closed completion.workflow.eventFilter.types +activity.issues reopened completion.workflow.eventFilter.types +activity.issues assigned completion.workflow.eventFilter.types +activity.issues unassigned completion.workflow.eventFilter.types +activity.issues labeled completion.workflow.eventFilter.types +activity.issues unlabeled completion.workflow.eventFilter.types +activity.issues locked completion.workflow.eventFilter.types +activity.issues unlocked completion.workflow.eventFilter.types +activity.issues milestoned completion.workflow.eventFilter.types +activity.issues demilestoned completion.workflow.eventFilter.types +activity.label created completion.workflow.eventFilter.types +activity.label edited completion.workflow.eventFilter.types +activity.label deleted completion.workflow.eventFilter.types +activity.merge_group checks_requested completion.workflow.eventFilter.types +activity.milestone created completion.workflow.eventFilter.types +activity.milestone closed completion.workflow.eventFilter.types +activity.milestone opened completion.workflow.eventFilter.types +activity.milestone edited completion.workflow.eventFilter.types +activity.milestone deleted completion.workflow.eventFilter.types +activity.pull_request assigned completion.workflow.eventFilter.types +activity.pull_request unassigned completion.workflow.eventFilter.types +activity.pull_request labeled completion.workflow.eventFilter.types +activity.pull_request unlabeled completion.workflow.eventFilter.types +activity.pull_request opened completion.workflow.eventFilter.types +activity.pull_request edited completion.workflow.eventFilter.types +activity.pull_request closed completion.workflow.eventFilter.types +activity.pull_request reopened completion.workflow.eventFilter.types +activity.pull_request synchronize completion.workflow.eventFilter.types +activity.pull_request converted_to_draft completion.workflow.eventFilter.types +activity.pull_request locked completion.workflow.eventFilter.types +activity.pull_request unlocked completion.workflow.eventFilter.types +activity.pull_request enqueued completion.workflow.eventFilter.types +activity.pull_request dequeued completion.workflow.eventFilter.types +activity.pull_request milestoned completion.workflow.eventFilter.types +activity.pull_request demilestoned completion.workflow.eventFilter.types +activity.pull_request ready_for_review completion.workflow.eventFilter.types +activity.pull_request review_requested completion.workflow.eventFilter.types +activity.pull_request review_request_removed completion.workflow.eventFilter.types +activity.pull_request auto_merge_enabled completion.workflow.eventFilter.types +activity.pull_request auto_merge_disabled completion.workflow.eventFilter.types +activity.pull_request_target assigned completion.workflow.eventFilter.types +activity.pull_request_target unassigned completion.workflow.eventFilter.types +activity.pull_request_target labeled completion.workflow.eventFilter.types +activity.pull_request_target unlabeled completion.workflow.eventFilter.types +activity.pull_request_target opened completion.workflow.eventFilter.types +activity.pull_request_target edited completion.workflow.eventFilter.types +activity.pull_request_target closed completion.workflow.eventFilter.types +activity.pull_request_target reopened completion.workflow.eventFilter.types +activity.pull_request_target synchronize completion.workflow.eventFilter.types +activity.pull_request_target converted_to_draft completion.workflow.eventFilter.types +activity.pull_request_target locked completion.workflow.eventFilter.types +activity.pull_request_target unlocked completion.workflow.eventFilter.types +activity.pull_request_target enqueued completion.workflow.eventFilter.types +activity.pull_request_target dequeued completion.workflow.eventFilter.types +activity.pull_request_target milestoned completion.workflow.eventFilter.types +activity.pull_request_target demilestoned completion.workflow.eventFilter.types +activity.pull_request_target ready_for_review completion.workflow.eventFilter.types +activity.pull_request_target review_requested completion.workflow.eventFilter.types +activity.pull_request_target review_request_removed completion.workflow.eventFilter.types +activity.pull_request_target auto_merge_enabled completion.workflow.eventFilter.types +activity.pull_request_target auto_merge_disabled completion.workflow.eventFilter.types +activity.pull_request_review submitted completion.workflow.eventFilter.types +activity.pull_request_review edited completion.workflow.eventFilter.types +activity.pull_request_review dismissed completion.workflow.eventFilter.types +activity.registry_package published completion.workflow.eventFilter.types +activity.registry_package updated completion.workflow.eventFilter.types +activity.release published completion.workflow.eventFilter.types +activity.release unpublished completion.workflow.eventFilter.types +activity.release created completion.workflow.eventFilter.types +activity.release edited completion.workflow.eventFilter.types +activity.release deleted completion.workflow.eventFilter.types +activity.release prereleased completion.workflow.eventFilter.types +activity.release released completion.workflow.eventFilter.types +activity.watch started completion.workflow.eventFilter.types +activity.workflow_run completed completion.workflow.eventFilter.types +activity.workflow_run requested completion.workflow.eventFilter.types +activity.workflow_run in_progress completion.workflow.eventFilter.types +permission actions completion.workflow.permission.actions +permission artifact-metadata completion.workflow.permission.artifact-metadata +permission attestations completion.workflow.permission.attestations +permission checks completion.workflow.permission.checks +permission code-quality completion.workflow.permission.code-quality +permission contents completion.workflow.permission.contents +permission deployments completion.workflow.permission.deployments +permission discussions completion.workflow.permission.discussions +permission id-token completion.workflow.permission.id-token +permission issues completion.workflow.permission.issues +permission models completion.workflow.permission.models +permission packages completion.workflow.permission.packages +permission pages completion.workflow.permission.pages +permission pull-requests completion.workflow.permission.pull-requests +permission security-events completion.workflow.permission.security-events +permission statuses completion.workflow.permission.statuses +permission vulnerability-alerts completion.workflow.permission.vulnerability-alerts +permissionValue read completion.workflow.permission.value.read +permissionValue write completion.workflow.permission.value.write +permissionValue none completion.workflow.permission.value.none +permissionValue.id-token write completion.workflow.permission.value.write +permissionValue.id-token none completion.workflow.permission.value.none +permissionValue.models read completion.workflow.permission.value.read +permissionValue.models none completion.workflow.permission.value.none +permissionValue.vulnerability-alerts read completion.workflow.permission.value.read +permissionValue.vulnerability-alerts none completion.workflow.permission.value.none +permissionShorthand read-all completion.workflow.permission.shorthand.read-all +permissionShorthand write-all completion.workflow.permission.shorthand.write-all +permissionShorthand {} completion.workflow.permission.shorthand.empty +job name completion.workflow.job.name +job permissions completion.workflow.job.permissions +job needs completion.workflow.job.needs +job if completion.workflow.job.if +job runs-on completion.workflow.job.runs-on +job snapshot completion.workflow.job.snapshot +job environment completion.workflow.job.environment +job concurrency completion.workflow.job.concurrency +job outputs completion.workflow.job.outputs +job env completion.workflow.job.env +job defaults completion.workflow.job.defaults +job steps completion.workflow.job.steps +job timeout-minutes completion.workflow.job.timeout-minutes +job strategy completion.workflow.job.strategy +job continue-on-error completion.workflow.job.continue-on-error +job container completion.workflow.job.container +job services completion.workflow.job.services +job uses completion.workflow.job.uses +job with completion.workflow.job.with +job secrets completion.workflow.job.secrets +defaultsRun shell completion.workflow.defaultsRun.shell +defaultsRun working-directory completion.workflow.defaultsRun.working-directory +concurrency group completion.workflow.concurrency.group +concurrency cancel-in-progress completion.workflow.concurrency.cancel-in-progress +environment name completion.workflow.environment.name +environment url completion.workflow.environment.url +strategy matrix completion.workflow.strategy.matrix +strategy fail-fast completion.workflow.strategy.fail-fast +strategy max-parallel completion.workflow.strategy.max-parallel +matrix include completion.workflow.matrix.include +matrix exclude completion.workflow.matrix.exclude +step id completion.workflow.step.id +step if completion.workflow.step.if +step name completion.workflow.step.name +step uses completion.workflow.step.uses +step run completion.workflow.step.run +step shell completion.workflow.step.shell +step with completion.workflow.step.with +step env completion.workflow.step.env +step continue-on-error completion.workflow.step.continue-on-error +step timeout-minutes completion.workflow.step.timeout-minutes +step working-directory completion.workflow.step.working-directory +container image completion.workflow.container.image +container credentials completion.workflow.container.credentials +container env completion.workflow.container.env +container ports completion.workflow.container.ports +container volumes completion.workflow.container.volumes +container options completion.workflow.container.options +service image completion.workflow.service.image +service credentials completion.workflow.service.credentials +service env completion.workflow.service.env +service ports completion.workflow.service.ports +service volumes completion.workflow.service.volumes +service options completion.workflow.service.options +credentials username completion.workflow.credentials.username +credentials password completion.workflow.credentials.password +inputType.workflow_dispatch string completion.workflow.inputType.string +inputType.workflow_dispatch boolean completion.workflow.inputType.boolean +inputType.workflow_dispatch choice completion.workflow.inputType.choice +inputType.workflow_dispatch number completion.workflow.inputType.number +inputType.workflow_dispatch environment completion.workflow.inputType.environment +inputType.workflow_call string completion.workflow.inputType.string +inputType.workflow_call boolean completion.workflow.inputType.boolean +inputType.workflow_call number completion.workflow.inputType.number +trigger.workflow_dispatch inputs completion.context.inputs +trigger.workflow_call inputs completion.context.inputs +trigger.workflow_call outputs completion.jobs.outputs +trigger.workflow_call secrets completion.context.secrets +boolean true completion.workflow.boolean.true +boolean false completion.workflow.boolean.false +runner ubuntu-latest completion.workflow.runner.ubuntu-latest +runner ubuntu-24.04 completion.workflow.runner.ubuntu-24.04 +runner ubuntu-22.04 completion.workflow.runner.ubuntu-22.04 +runner windows-latest completion.workflow.runner.windows-latest +runner windows-2025 completion.workflow.runner.windows-2025 +runner windows-2022 completion.workflow.runner.windows-2022 +runner macos-latest completion.workflow.runner.macos-latest +runner macos-15 completion.workflow.runner.macos-15 +runner macos-14 completion.workflow.runner.macos-14 +runner self-hosted completion.workflow.runner.self-hosted diff --git a/src/main/resources/icons/gitea.svg b/src/main/resources/icons/gitea.svg new file mode 100644 index 0000000..87134f4 --- /dev/null +++ b/src/main/resources/icons/gitea.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/gitea_dark.svg b/src/main/resources/icons/gitea_dark.svg new file mode 100644 index 0000000..c95cc36 --- /dev/null +++ b/src/main/resources/icons/gitea_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowActionRegistrationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java similarity index 60% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowActionRegistrationTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java index 590364a..f1fb3fb 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowActionRegistrationTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/entry/PluginWiringTest.java @@ -1,4 +1,10 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.entry; + +import com.github.yunabraska.githubworkflow.run.WorkflowRunConfiguration; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.openapi.actionSystem.ActionGroup; import com.intellij.openapi.actionSystem.ActionManager; @@ -10,9 +16,25 @@ import com.intellij.execution.configurations.ConfigurationTypeUtil; import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; -public class WorkflowActionRegistrationTest extends BasePlatformTestCase { +public class PluginWiringTest extends BasePlatformTestCase { + + private static final List SCHEMA_NAMES = List.of( + "dependabot-2.0", + "github-action", + "github-funding", + "github-workflow", + "github-discussion", + "github-issue-forms", + "github-issue-config", + "github-workflow-template-properties" + ); public void testCacheActionGroupIsRegisteredFromPluginXml() { final AnAction action = ActionManager.getInstance().getAction("GitHubWorkflow.Tools"); @@ -22,35 +44,35 @@ public void testCacheActionGroupIsRegisteredFromPluginXml() { assertThat(action.getTemplatePresentation().getDescription()).isEqualTo("GitHub Workflow plugin tools"); } - public void testRefreshActionCacheActionIsRegisteredAndLocalized() { + public void testGitHubActionCacheRefreshActionIsRegisteredAndLocalized() { final AnAction action = ActionManager.getInstance().getAction("GitHubWorkflow.RefreshActionCache"); - assertThat(action).isInstanceOf(RefreshActionCacheAction.class); + assertThat(action).isInstanceOf(GitHubActionCache.RefreshAction.class); assertThat(action.getTemplatePresentation().getText()).isEqualTo("Refresh Action Cache"); assertThat(action.getTemplatePresentation().getDescription()) .isEqualTo("Refresh resolved remote GitHub Actions and reusable workflow metadata"); } - public void testClearActionCacheActionIsRegisteredAndLocalized() { + public void testGitHubActionCacheClearActionIsRegisteredAndLocalized() { final AnAction action = ActionManager.getInstance().getAction("GitHubWorkflow.ClearActionCache"); - assertThat(action).isInstanceOf(ClearActionCacheAction.class); + assertThat(action).isInstanceOf(GitHubActionCache.ClearAction.class); assertThat(action.getTemplatePresentation().getText()).isEqualTo("Clear Action Cache"); assertThat(action.getTemplatePresentation().getDescription()) .isEqualTo("Clear cached GitHub Actions and reusable workflow metadata"); } - public void testRestoreActionWarningsActionIsRegisteredAndLocalized() { + public void testGitHubActionCacheRestoreWarningsActionIsRegisteredAndLocalized() { final AnAction action = ActionManager.getInstance().getAction("GitHubWorkflow.RestoreActionWarnings"); - assertThat(action).isInstanceOf(RestoreActionWarningsAction.class); + assertThat(action).isInstanceOf(GitHubActionCache.RestoreWarningsAction.class); assertThat(action.getTemplatePresentation().getText()).isEqualTo("Restore Action Warnings"); assertThat(action.getTemplatePresentation().getDescription()) .isEqualTo("Restore suppressed action, input, and output validation warnings"); } public void testActionUpdateUsesConfiguredPluginLanguageOverride() { - final PluginSettings settings = PluginSettings.getInstance(); + final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); final String previousLanguage = settings.languageTag(); try { settings.languageTag("de"); @@ -73,10 +95,23 @@ public void testActionUpdateUsesConfiguredPluginLanguageOverride() { } } - public void testWorkflowRunConfigurationTypeIsRegistered() { - final WorkflowRunConfigurationType type = ConfigurationTypeUtil.findConfigurationType(WorkflowRunConfigurationType.class); + public void testWorkflowRunConfigurationIsRegistered() { + final WorkflowRunConfiguration.Type type = ConfigurationTypeUtil.findConfigurationType(WorkflowRunConfiguration.Type.class); - assertThat(type.getId()).isEqualTo(WorkflowRunConfigurationType.ID); + assertThat(type.getId()).isEqualTo(WorkflowRunConfiguration.Type.ID); assertThat(type.getConfigurationFactories()).hasSize(1); } + + public void testPackagedSchemasArePresentAndNonEmpty() throws IOException { + final Path directory = Path.of(System.getProperty("user.dir"), "src", "main", "resources", "schemas"); + + for (final String schemaName : SCHEMA_NAMES) { + final Path schema = directory.resolve(schemaName + ".json"); + assertThat(schema).exists().isRegularFile(); + assertThat(Files.readString(schema)) + .startsWith("{") + .contains("\"$schema\"") + .contains("\"$id\""); + } + } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/RemoteActionProvidersTest.java b/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java similarity index 80% rename from src/test/java/com/github/yunabraska/githubworkflow/services/RemoteActionProvidersTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java index ee6c324..55a8f3e 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/RemoteActionProvidersTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/git/RemoteActionProvidersTest.java @@ -1,4 +1,8 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.git; + +import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.testFramework.fixtures.BasePlatformTestCase; @@ -13,7 +17,7 @@ public class RemoteActionProvidersTest extends BasePlatformTestCase { @Override protected void tearDown() throws Exception { try { - RemoteServerSettings.getInstance().setCustomServers(List.of()); + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of()); } finally { super.tearDown(); } @@ -212,8 +216,50 @@ public void testSearchUsesReturnsMatchingRepositoriesFromConfiguredServer() thro } } + public void testStandardEnvironmentTokensAreTriedBeforeAnonymous() { + final List authorizations = RemoteActionProviders.Authorizations.forApiUrl( + "https://api.example.test", + "", + null, + Map.of("GITHUB_TOKEN", "env-token") + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsSubsequence("GITHUB_TOKEN", "anonymous"); + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::authorizationHeader) + .contains("Bearer env-token"); + } + + public void testExplicitEnvironmentTokenIsTriedBeforeStandardEnvironmentTokens() { + final List authorizations = RemoteActionProviders.Authorizations.forApiUrl( + "https://github.acme.test/api/v3", + "ACME_GITHUB_TOKEN", + null, + Map.of("ACME_GITHUB_TOKEN", "enterprise-token", "GITHUB_TOKEN", "default-token") + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsSubsequence("ACME_GITHUB_TOKEN", "GITHUB_TOKEN", "anonymous"); + } + + public void testMissingEnvironmentTokensFallBackToAnonymous() { + final List authorizations = RemoteActionProviders.Authorizations.forApiUrl( + "https://github.acme.test/api/v3", + "ACME_GITHUB_TOKEN", + null, + Map.of() + ); + + assertThat(authorizations) + .extracting(RemoteActionProviders.Authorizations.Authorization::source) + .containsExactly("anonymous"); + } + private static void useServer(final FakeRemoteServer server, final String apiPrefix) { - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server( + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server( "Fake", server.webUrl(), server.apiUrl(apiPrefix), diff --git a/src/test/java/com/github/yunabraska/githubworkflow/git/WorkflowLocationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/git/WorkflowLocationTest.java new file mode 100644 index 0000000..6e20069 --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/git/WorkflowLocationTest.java @@ -0,0 +1,89 @@ +package com.github.yunabraska.githubworkflow.git; + +import com.github.yunabraska.githubworkflow.git.WorkflowLocation; + +import junit.framework.TestCase; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowLocationTest extends TestCase { + + public void testGithubHttpsRemoteUsesPublicApi() { + assertThat(WorkflowLocation.RepositoryResolver.fromRemoteUrl("https://github.com/YunaBraska/github-workflow-plugin.git")) + .contains(new WorkflowLocation.Repository( + "https://github.com", + "https://api.github.com", + "YunaBraska", + "github-workflow-plugin" + )); + } + + public void testEnterpriseHttpsRemoteUsesApiV3() { + assertThat(WorkflowLocation.RepositoryResolver.fromRemoteUrl("https://github.acme.test/tools/workflows.git")) + .contains(new WorkflowLocation.Repository( + "https://github.acme.test", + "https://github.acme.test/api/v3", + "tools", + "workflows" + )); + } + + public void testSshRemoteUsesPublicApi() { + assertThat(WorkflowLocation.RepositoryResolver.fromRemoteUrl("git@github.com:YunaBraska/github-workflow-plugin.git")) + .contains(new WorkflowLocation.Repository( + "https://github.com", + "https://api.github.com", + "YunaBraska", + "github-workflow-plugin" + )); + } + + public void testResolveReadsOriginFromGitConfig() throws Exception { + final Path dir = Files.createTempDirectory("workflow-repo"); + Files.createDirectories(dir.resolve(".git")); + Files.writeString(dir.resolve(".git").resolve("config"), """ + [remote "origin"] + url = https://github.com/YunaBraska/github-workflow-plugin.git + """); + + assertThat(new WorkflowLocation.RepositoryResolver().resolve(dir)) + .contains(new WorkflowLocation.Repository( + "https://github.com", + "https://api.github.com", + "YunaBraska", + "github-workflow-plugin" + )); + } + + public void testBranchNameReadsRefsHeadsBranch() { + assertThat(WorkflowLocation.RepositoryResolver.branchName("ref: refs/heads/feature/logs\n")) + .contains("feature/logs"); + } + + public void testBranchNameIgnoresDetachedHead() { + assertThat(WorkflowLocation.RepositoryResolver.branchName("e1a9e573f4d0838b3a7c1b07401aeb29ed3635a9")) + .isEmpty(); + } + + public void testResolveReadsCurrentBranchFromGitHead() throws Exception { + final Path dir = Files.createTempDirectory("workflow-branch"); + Files.createDirectories(dir.resolve(".git")); + Files.writeString(dir.resolve(".git").resolve("HEAD"), "ref: refs/heads/feature/current\n"); + + assertThat(new WorkflowLocation.RepositoryResolver().branch(dir)) + .contains("feature/current"); + } + + public void testResolveReadsCurrentBranchFromWorktreeGitFile() throws Exception { + final Path dir = Files.createTempDirectory("workflow-worktree"); + final Path gitDir = Files.createDirectories(dir.resolve("real-git-dir")); + Files.writeString(dir.resolve(".git"), "gitdir: real-git-dir\n"); + Files.writeString(gitDir.resolve("HEAD"), "ref: refs/heads/worktree/current\n"); + + assertThat(new WorkflowLocation.RepositoryResolver().branch(dir)) + .contains("worktree/current"); + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/helper/FileDownloaderTest.java b/src/test/java/com/github/yunabraska/githubworkflow/helper/FileDownloaderTest.java deleted file mode 100644 index 7bb3f13..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/helper/FileDownloaderTest.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import com.sun.net.httpserver.HttpServer; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; - -public class FileDownloaderTest { - - private HttpServer server; - private String baseUrl; - - @Before - public void startServer() throws IOException { - server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); - baseUrl = "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); - server.start(); - } - - @After - public void stopServer() { - if (server != null) { - server.stop(0); - } - } - - @Test - public void downloadSyncReadsSuccessfulResponseAndSendsHeaders() { - final AtomicReference userAgent = new AtomicReference<>(); - final AtomicReference clientName = new AtomicReference<>(); - server.createContext("/ok", exchange -> { - userAgent.set(exchange.getRequestHeaders().getFirst("User-Agent")); - clientName.set(exchange.getRequestHeaders().getFirst("Client-Name")); - final byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, bytes.length); - exchange.getResponseBody().write(bytes); - exchange.close(); - }); - - final String result = FileDownloader.downloadSync(baseUrl + "/ok", "JUnitAgent/1"); - - assertThat(result).isEqualTo("hello" + System.lineSeparator()); - assertThat(userAgent).hasValue("JUnitAgent/1"); - assertThat(clientName).hasValue("GitHub Workflow Plugin"); - } - - @Test - public void downloadSyncReturnsEmptyStringForHttpFailures() { - server.createContext("/missing", exchange -> { - exchange.sendResponseHeaders(404, -1); - exchange.close(); - }); - - assertThat(FileDownloader.downloadSync(baseUrl + "/missing", "JUnitAgent/1")).isEmpty(); - } - - @Test - public void downloadSyncReturnsEmptyStringForSlowResponses() { - server.createContext("/slow", exchange -> { - try { - Thread.sleep(1500); - exchange.sendResponseHeaders(200, -1); - } catch (final InterruptedException interrupted) { - Thread.currentThread().interrupt(); - } finally { - exchange.close(); - } - }); - - assertThat(FileDownloader.downloadSync(baseUrl + "/slow", "JUnitAgent/1")).isEmpty(); - } - - @Test - public void downloadSyncReturnsEmptyStringForInvalidUrls() { - assertThat(FileDownloader.downloadSync("://not-a-url", "JUnitAgent/1")).isEmpty(); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelperTest.java b/src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelperTest.java deleted file mode 100644 index 7b90002..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowHelperTest.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.github.yunabraska.githubworkflow.helper; - -import org.junit.Test; - -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -public class GitHubWorkflowHelperTest { - - @Test - public void detectsWorkflowFilesOnlyUnderGithubWorkflowsDirectory() { - assertThat(GitHubWorkflowHelper.isWorkflowFile(Path.of("repo", ".github", "workflows", "build.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isWorkflowFile(Path.of("repo", ".github", "workflows", "build.yaml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isWorkflowFile(Path.of("repo", ".gitea", "workflows", "build.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isWorkflowFile(Path.of("repo", ".github", "not-workflows", "build.yml"))).isFalse(); - assertThat(GitHubWorkflowHelper.isWorkflowFile(Path.of("repo", "workflows", "build.yml"))).isFalse(); - } - - @Test - public void invalidVirtualFilePathTextIsRejectedWithoutThrowing() { - assertThat(PsiElementHelper.toPath("<36ba1c43-b8f1-4f54-ace0-cef443d1e8f0>/etc/php/8.1/apache2/php.ini")).isEmpty(); - } - - @Test - public void serializedVirtualFilePathTextIsRejectedWithoutThrowing() { - assertThat(PsiElementHelper.toPath("{\"sessionId\":\"2cc03ab1-37d6-47cd-980d-1bb135073b4d\"}")).isEmpty(); - } - - @Test - public void detectsActionMetadataFilesByName() { - assertThat(GitHubWorkflowHelper.isActionFile(Path.of("repo", "action.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isActionFile(Path.of("repo", "nested", "ACTION.YAML"))).isTrue(); - assertThat(GitHubWorkflowHelper.isActionFile(Path.of("repo", "workflow.yml"))).isFalse(); - } - - @Test - public void detectsSchemaTargetFiles() { - assertThat(GitHubWorkflowHelper.isDependabotFile(Path.of("repo", ".github", "dependabot.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isFoundingFile(Path.of("repo", ".github", "FUNDING.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isIssueForms(Path.of("repo", ".github", "ISSUE_TEMPLATE", "bug.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isDiscussionFile(Path.of("repo", ".github", "DISCUSSION_TEMPLATE", "question.yaml"))).isTrue(); - } - - @Test - public void rejectsIssueConfigOutsideIssueTemplateDirectory() { - assertThat(GitHubWorkflowHelper.isIssueConfigFile(Path.of("repo", ".github", "ISSUE_TEMPLATE", "config.yml"))).isTrue(); - assertThat(GitHubWorkflowHelper.isIssueConfigFile(Path.of("repo", ".github", "workflow-templates", "config.yml"))).isFalse(); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/LocalizationResourcesTest.java b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java similarity index 93% rename from src/test/java/com/github/yunabraska/githubworkflow/services/LocalizationResourcesTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java index 5ca16a9..51c4902 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/LocalizationResourcesTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/i18n/WorkflowMessagesTest.java @@ -1,7 +1,12 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.i18n; +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; + +import com.intellij.openapi.diagnostic.IdeaLoggingEvent; +import com.intellij.openapi.diagnostic.SubmittedReportInfo; import org.junit.Test; +import javax.swing.JPanel; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -10,11 +15,12 @@ import java.util.Locale; import java.util.Properties; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; -public class LocalizationResourcesTest { +public class WorkflowMessagesTest { private static final String BUNDLE_PATH = "messages/GitHubWorkflowBundle"; private static final List LOCALE_SUFFIXES = List.of( @@ -48,6 +54,18 @@ public void testDefaultBundleReturnsActionCacheMessages() { .isEqualTo("Cleared 3 cached GitHub Workflow entries."); } + @Test + public void testErrorReporterFailsExplicitlyForEmptyEvents() { + final GitHubWorkflowBundle.ErrorReporter submitter = new GitHubWorkflowBundle.ErrorReporter(); + final AtomicReference reportInfo = new AtomicReference<>(); + + final boolean submitted = submitter.submit(new IdeaLoggingEvent[0], "", new JPanel(), reportInfo::set); + + assertThat(submitted).isFalse(); + assertThat(reportInfo.get()).isNotNull(); + assertThat(reportInfo.get().getStatus()).isEqualTo(SubmittedReportInfo.SubmissionStatus.FAILED); + } + @Test public void testTopTwentyLocaleBundlesHaveTheSameKeysAsDefaultBundle() throws IOException { final Properties defaultBundle = loadBundle(""); @@ -314,7 +332,7 @@ public void testCoreInspectionMessagesAreLocalizedForEveryLocale() throws IOExce private static Properties loadBundle(final String suffix) throws IOException { final String path = BUNDLE_PATH + suffix + ".properties"; - try (InputStream stream = LocalizationResourcesTest.class.getClassLoader().getResourceAsStream(path)) { + try (InputStream stream = WorkflowMessagesTest.class.getClassLoader().getResourceAsStream(path)) { assertThat(stream).as("Bundle [%s] exists", path).isNotNull(); final Properties properties = new Properties(); properties.load(new InputStreamReader(stream, StandardCharsets.UTF_8)); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/model/WorkflowCallableTest.java b/src/test/java/com/github/yunabraska/githubworkflow/model/WorkflowCallableTest.java new file mode 100644 index 0000000..6712d2b --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/model/WorkflowCallableTest.java @@ -0,0 +1,104 @@ +package com.github.yunabraska.githubworkflow.model; + +import org.junit.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowCallableTest { + + @Test + public void createGithubActionBuildsRemoteActionUrls() { + final GitHubAction action = GitHubAction.createGithubAction(false, "actions/setup-java@v4", "actions/setup-java@v4"); + + assertThat(action.name()).isEqualTo("actions/setup-java"); + assertThat(action.usesValue()).isEqualTo("actions/setup-java@v4"); + assertThat(action.downloadUrl()).isEqualTo("https://raw.githubusercontent.com/actions/setup-java/v4/action.yml"); + assertThat(action.githubUrl()).isEqualTo("https://github.com/actions/setup-java/tree/v4#readme"); + assertThat(action.isLocal()).isFalse(); + assertThat(action.isAction()).isTrue(); + assertThat(action.isResolved()).isFalse(); + } + + @Test + public void createGithubActionBuildsNestedRemoteActionUrls() { + final GitHubAction action = GitHubAction.createGithubAction(false, "owner/repo/path/to/action@main", "owner/repo/path/to/action@main"); + + assertThat(action.name()).isEqualTo("owner/repo/path/to/action"); + assertThat(action.downloadUrl()).isEqualTo("https://raw.githubusercontent.com/owner/repo/main/path/to/action/action.yml"); + assertThat(action.githubUrl()).isEqualTo("https://github.com/owner/repo/tree/main/path/to/action#readme"); + assertThat(action.isAction()).isTrue(); + } + + @Test + public void createGithubActionKeepsNestedRemoteActionNameForIssue48() { + final GitHubAction action = GitHubAction.createGithubAction(false, "github/codeql-action/init@v2", "github/codeql-action/init@v2"); + + assertThat(action.name()).isEqualTo("github/codeql-action/init"); + assertThat(action.downloadUrl()).isEqualTo("https://raw.githubusercontent.com/github/codeql-action/v2/init/action.yml"); + assertThat(action.githubUrl()).isEqualTo("https://github.com/github/codeql-action/tree/v2/init#readme"); + assertThat(action.isAction()).isTrue(); + } + + @Test + public void createGithubActionBuildsReusableWorkflowUrls() { + final GitHubAction action = GitHubAction.createGithubAction(false, "owner/repo/.github/workflows/reuse.yml@main", "owner/repo/.github/workflows/reuse.yml@main"); + + assertThat(action.name()).isEqualTo("owner/repo"); + assertThat(action.downloadUrl()).isEqualTo("https://raw.githubusercontent.com/owner/repo/main/.github/workflows/reuse.yml"); + assertThat(action.githubUrl()).isEqualTo("https://github.com/owner/repo/blob/main/.github/workflows/reuse.yml"); + assertThat(action.isAction()).isFalse(); + } + + @Test + public void createGithubActionTreatsLocalWorkflowFileAsReusableWorkflow() { + final GitHubAction action = GitHubAction.createGithubAction(true, "./.github/workflows/reusable.yml", "/tmp/project/.github/workflows/reusable.yml"); + + assertThat(action.isLocal()).isTrue(); + assertThat(action.isAction()).isFalse(); + } + + @Test + public void createGithubActionTreatsLocalActionDirectoryAsAction() { + final GitHubAction action = GitHubAction.createGithubAction(true, "./.github/actions/local", "/tmp/project/.github/actions/local/action.yml"); + + assertThat(action.isLocal()).isTrue(); + assertThat(action.isAction()).isTrue(); + } + + @Test + public void settersIgnoreNullMapsAndKeepFluentApi() { + final GitHubAction action = new GitHubAction() + .setInputs(null) + .setOutputs(null) + .setSecrets(null) + .setMetaData(null) + .setInputs(Map.of("input", "description")) + .setOutputs(Map.of("output", "description")) + .setSecrets(Map.of("secret", "description")) + .setMetaData(Map.of("name", "demo", "ignoredInputs", "manual-input", "ignoredOutputs", "manual-output")); + + assertThat(action.getInputs()).containsEntry("input", "description"); + assertThat(action.getOutputs()).containsEntry("output", "description"); + assertThat(action.getSecrets()).containsEntry("secret", "description"); + assertThat(action.freshSecrets()).containsEntry("secret", "description"); + assertThat(action.name()).isEqualTo("demo"); + assertThat(action.ignoredInputs()).contains("manual-input"); + assertThat(action.ignoredOutputs()).contains("manual-output"); + } + + @Test + public void suppressedItemsCanBeRemovedAgain() { + final GitHubAction action = new GitHubAction() + .suppressInput("manual-input", true) + .suppressOutput("manual-output", true); + + action.suppressInput("manual-input", false); + action.suppressOutput("manual-output", false); + + assertThat(action.ignoredInputs()).doesNotContain("manual-input"); + assertThat(action.ignoredOutputs()).doesNotContain("manual-output"); + assertThat(action.getMetaData()).containsEntry("ignoredInputs", "").containsEntry("ignoredOutputs", ""); + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowGutterActionTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowGutterTest.java similarity index 88% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowGutterActionTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowGutterTest.java index 897f7cc..ed5aca0 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowGutterActionTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowGutterTest.java @@ -1,4 +1,8 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.execution.lineMarker.RunLineMarkerContributor; @@ -19,9 +23,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_ENV; import static com.github.yunabraska.githubworkflow.model.NodeIcon.ICON_TEXT_VARIABLE; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.getActionCache; -public class WorkflowGutterActionTest extends EditorFeatureTestCase { +public class WorkflowGutterTest extends EditorFeatureTestCase { public void testSuppressActionQuickFixTogglesResolvedAction() { final GitHubAction action = seedRemoteAction("owner/tool@v1", Map.of(), Map.of()); @@ -161,8 +165,8 @@ public void testWorkflowDispatchShowsRunLineMarker() throws Exception { steps: - run: echo ok """); - final WorkflowRunLineMarkerContributor.RepositoryAvailability previous = - WorkflowRunLineMarkerContributor.useRepositoryAvailabilityForTests((project, file) -> true); + final WorkflowRun.LineMarkerContributor.RepositoryAvailability previous = + WorkflowRun.LineMarkerContributor.useRepositoryAvailabilityForTests((project, file) -> true); try { final YAMLKeyValue dispatch = PsiTreeUtil.findChildrenOfType(myFixture.getFile(), YAMLKeyValue.class) .stream() @@ -170,12 +174,12 @@ public void testWorkflowDispatchShowsRunLineMarker() throws Exception { .findFirst() .orElseThrow(); - final RunLineMarkerContributor.Info info = new WorkflowRunLineMarkerContributor().getInfo(dispatch.getKey()); + final RunLineMarkerContributor.Info info = new WorkflowRun.LineMarkerContributor().getInfo(dispatch.getKey()); assertThat(info).isNotNull(); assertThat(info.actions).isNotEmpty(); } finally { - WorkflowRunLineMarkerContributor.useRepositoryAvailabilityForTests(previous); + WorkflowRun.LineMarkerContributor.useRepositoryAvailabilityForTests(previous); } } @@ -197,7 +201,7 @@ public void testWorkflowDispatchDoesNotShowRunLineMarkerWithoutGitRepository() { .findFirst() .orElseThrow(); - final RunLineMarkerContributor.Info info = new WorkflowRunLineMarkerContributor().getInfo(dispatch.getKey()); + final RunLineMarkerContributor.Info info = new WorkflowRun.LineMarkerContributor().getInfo(dispatch.getKey()); assertThat(info).isNull(); } @@ -213,10 +217,10 @@ public void testWorkflowDispatchLineMarkerSwitchesToStopWhenRunIsTracked() { steps: - run: echo ok """); - final String workflowPath = WorkflowRunConfigurationProducer.workflowPath(getProject(), myFixture.getFile().getVirtualFile()) + final String workflowPath = WorkflowRunConfiguration.Producer.workflowPath(getProject(), myFixture.getFile().getVirtualFile()) .orElseThrow(); final DummyProcessHandler processHandler = new DummyProcessHandler(); - WorkflowRunTracker.getInstance(getProject()).register(workflowPath, processHandler); + WorkflowRun.Tracker.getInstance(getProject()).register(workflowPath, processHandler); try { final YAMLKeyValue dispatch = PsiTreeUtil.findChildrenOfType(myFixture.getFile(), YAMLKeyValue.class) .stream() @@ -224,18 +228,18 @@ public void testWorkflowDispatchLineMarkerSwitchesToStopWhenRunIsTracked() { .findFirst() .orElseThrow(); - final RunLineMarkerContributor.Info info = new WorkflowRunLineMarkerContributor().getInfo(dispatch.getKey()); + final RunLineMarkerContributor.Info info = new WorkflowRun.LineMarkerContributor().getInfo(dispatch.getKey()); assertThat(info).isNotNull(); assertThat(info.icon).isEqualTo(AllIcons.Actions.Suspend); assertThat(info.actions).singleElement() .satisfies(action -> assertThat(action.getTemplatePresentation().getText()).isEqualTo("Stop workflow run")); } finally { - WorkflowRunTracker.getInstance(getProject()).unregister(workflowPath, processHandler); + WorkflowRun.Tracker.getInstance(getProject()).unregister(workflowPath, processHandler); } } - private static final class DummyProcessHandler extends ProcessHandler { + private static class DummyProcessHandler extends ProcessHandler { @Override protected void destroyProcessImpl() { diff --git a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java new file mode 100644 index 0000000..f1c970c --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowPresentationTest.java @@ -0,0 +1,738 @@ +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.entry.WorkflowAnnotator; + +import com.github.yunabraska.githubworkflow.entry.WorkflowDocumentationProvider; + +import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.model.GitHubAction; +import com.intellij.lang.Language; +import com.intellij.lang.injection.InjectedLanguageManager; +import com.intellij.openapi.util.Pair; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.yaml.psi.YAMLScalar; + +import java.util.List; +import java.util.Map; + +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.getActionCache; +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowPresentationTest extends EditorFeatureTestCase { + + public void testUsesDocumentationShowsResolvedActionMetadata() { + final GitHubAction action = seedRemoteAction( + "actions/setup-java@v4", + Map.of("distribution", "Description: Java distribution\nRequired: true\nDefault: temurin"), + Map.of("cache-hit", "Description: Whether cache was restored") + ); + action.displayName("Setup Java") + .description("Set up a specific version of Java and add it to PATH."); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-java@v4 + """); + + assertThat(documentationHintAtCaret()) + .contains("Setup Java") + .contains("actions/setup-java@v4"); + } + + public void testInputVariableDocumentationShowsMetadata() { + configureWorkflowProjectFile(""" + name: Docs + on: + workflow_dispatch: + inputs: + tag: + description: Release tag + required: true + type: string + default: v1 + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ inputs.tag }}" + """); + + assertThat(documentationHintAtCaret()) + .contains("Input tag") + .contains("Description: Release tag") + .contains("Type: string") + .contains("Required: true") + .contains("Default: v1"); + } + + public void testActionInputDocumentationShowsResolvedActionParameter() { + seedRemoteAction( + "actions/setup-java@v4", + Map.of("distribution", "Description: Java distribution\nRequired: true\nDefault: temurin"), + Map.of() + ); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-java@v4 + with: + distribution: temurin + """); + + assertThat(documentationHintAtCaret()) + .contains("Input distribution") + .contains("Java distribution") + .contains("Required: true") + .contains("Default: temurin"); + } + + public void testActionInputHoverDocumentationShowsResolvedActionParameter() { + seedRemoteAction( + "actions/checkout@v4", + Map.of("fetch-depth", "Description: Number of commits to fetch\nDefault: 1"), + Map.of() + ); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 500 + """); + + assertThat(documentationHtmlAtCaret()) + .contains("Input") + .contains("fetch-depth") + .contains("Number of commits to fetch") + .contains("Default"); + } + + public void testStepOutputDocumentationShowsOutputName() { + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: java_info + run: echo "is_gradle=true" >> "$GITHUB_OUTPUT" + - run: echo "${{ steps.java_info.outputs.is_gradle }}" + """); + + assertThat(documentationHintAtCaret()).contains("Step output is_gradle"); + } + + public void testActionOutputDocumentationShowsResolvedDescription() { + seedRemoteAction("owner/tool@v1", Map.of(), Map.of("artifact", "Description: Artifact path")); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: package + uses: owner/tool@v1 + - run: echo "${{ steps.package.outputs.artifact }}" + """); + + assertThat(documentationHintAtCaret()) + .contains("Step output artifact") + .contains("Description: Artifact path"); + } + + public void testActionOutputDocumentationShowsSourceStepAndActionLink() { + final GitHubAction action = seedRemoteAction( + "YunaBraska/java-info-action@main", + Map.of(), + Map.of("project_version", "Description: Project version\nType: string") + ); + action.displayName("Java Info").description("Reads Java metadata."); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Read Java Info + id: java_info + uses: YunaBraska/java-info-action@main + - run: echo "${{ steps.java_info.outputs.project_version }}" + """); + + assertThat(documentationHintAtCaret()) + .contains("Step output project_version") + .contains("Description: Project version") + .contains("Step: Read Java Info (java_info)") + .contains("Uses: YunaBraska/java-info-action@main") + .contains("External action: Java Info - Reads Java metadata."); + assertThat(documentationHtmlAtCaret()) + .contains("href=\"https://github.com/YunaBraska/java-info-action/tree/main#readme\"") + .contains(">YunaBraska/java-info-action@main"); + } + + public void testJobOutputDocumentationShowsMappedStepActionOutput() { + final GitHubAction action = seedRemoteAction( + "YunaBraska/java-info-action@main", + Map.of(), + Map.of("java_version", "Description: Java version\nType: string") + ); + action.displayName("Java Info").description("Reads Java metadata."); + + configureWorkflowProjectFile(""" + name: Docs + on: + workflow_call: + outputs: + java_version: + description: "[String] java version from pom file" + value: ${{ jobs.tag.outputs.java_version }} + jobs: + tag: + runs-on: ubuntu-latest + outputs: + java_version: ${{ steps.java_info.outputs.java_version }} + steps: + - name: Read Java Info + id: java_info + uses: YunaBraska/java-info-action@main + """); + + assertThat(documentationHintAtCaret()) + .contains("Reusable workflow job output java_version") + .contains("Java Info") + .contains("Reads Java metadata"); + } + + public void testStepDocumentationShowsResolvedActionNameAndDescription() { + final GitHubAction action = seedRemoteAction("YunaBraska/java-info-action@main", Map.of(), Map.of()); + action.displayName("Java Info").description("Reads Java metadata."); + + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Read Java Info + id: java_info + uses: YunaBraska/java-info-action@main + - run: echo "${{ steps.java_info.outputs.java_version }}" + """); + + assertThat(documentationHtmlAtCaret()) + .contains("Step") + .contains("Read Java Info") + .contains("Java Info") + .contains("Reads Java metadata"); + } + + public void testExpressionContextDocumentationShowsCollectionMeaning() { + configureWorkflowProjectFile(""" + name: Docs + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: java_info + run: echo "is_gradle=true" >> "$GITHUB_OUTPUT" + - run: echo "${{ steps.java_info.outputs.is_gradle }}" + """); + + assertThat(documentationHintAtCaret()).contains("Output values exposed"); + } + + private String documentationHintAtCaret() { + final WorkflowDocumentationProvider provider = new WorkflowDocumentationProvider(); + final PsiElement context = elementAtCaret(); + final PsiElement target = provider.getCustomDocumentationElement( + myFixture.getEditor(), + myFixture.getFile(), + context, + myFixture.getCaretOffset() + ); + assertThat(target).isNotNull(); + return provider.getQuickNavigateInfo(target, context); + } + + private String documentationHtmlAtCaret() { + final WorkflowDocumentationProvider provider = new WorkflowDocumentationProvider(); + final PsiElement context = elementAtCaret(); + final PsiElement target = provider.getCustomDocumentationElement( + myFixture.getEditor(), + myFixture.getFile(), + context, + myFixture.getCaretOffset() + ); + assertThat(target).isNotNull(); + return provider.generateHoverDoc(target, context); + } + + private PsiElement elementAtCaret() { + final PsiElement element = myFixture.getFile().findElementAt(myFixture.getCaretOffset()); + if (element != null) { + return element; + } + return myFixture.getFile().findElementAt(Math.max(0, myFixture.getCaretOffset() - 1)); + } + + public void testResolvedRemoteActionUseIsStyledAsReference() { + seedRemoteAction("owner/tool@v1", Map.of(), Map.of()); + + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/tool@v1 + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testConfiguredGithubEnterpriseRemoteActionUseIsStyledAsReference() throws Exception { + try (FakeRemoteServer server = new FakeRemoteServer()) { + server.addContent("acme", "tool", "action.yml", "main", """ + name: Enterprise Tool + runs: + using: composite + steps: + - run: echo ok + shell: sh + """); + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server("Fake Enterprise", + server.webUrl(), + server.apiUrl("/api/v3"), + "", + true + ))); + final String usesValue = server.webUrl() + "/acme/tool@main"; + final GitHubAction action = GitHubAction.createGithubAction(false, usesValue, usesValue).resolve(); + getActionCache().getState().actions.put(usesValue, action); + + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: %s/acme/tool@main + """.formatted(server.webUrl())); + + assertHighlightedReferenceAtCurrentCaret(); + } + } + + public void testResolvedLocalWorkflowUseIsStyledAsReference() { + seedLocalAction("./.github/workflows/reusable.yml", myFixture.addFileToProject(".github/workflows/reusable.yml", """ + name: Reusable + on: workflow_call + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """)); + + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + call: + uses: ./.github/workflows/reusable.yml + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testWorkflowInputExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: + workflow_call: + inputs: + known-input: + type: string + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ inputs.known-input }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testWorkflowInputExpressionUsesWorkflowVariableColor() { + configureWorkflowProjectFile(""" + name: Styling + on: + workflow_call: + inputs: + known-input: + type: string + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ inputs.known-input }}" + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + } + + public void testUnresolvedExpressionContextSegmentUsesWorkflowVariableColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ steps.pom.outputs.has_pom }}" + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + } + + public void testAutomaticGithubTokenSecretUsesWorkflowVariableColorWithoutReferenceRequirement() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ secrets.GITHUB_TOKEN }}" + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + } + + public void testIfExpressionWithoutBracesUsesWorkflowVariableColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + } + + public void testRunCommandGithubOutputUsesRunnerVariableColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "java_version=21" >> "$GITHUB_OUTPUT" + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.RUNNER_VARIABLE); + } + + public void testBooleanAndNumberScalarsUseLiteralColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/tool@v1 + with: + generateReleaseNotes: true + fetch-depth: 500 + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.SCALAR_LITERAL); + } + + public void testNumberScalarsUseLiteralColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: owner/tool@v1 + with: + fetch-depth: 500 + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.SCALAR_LITERAL); + } + + public void testJobIdUsesWorkflowDeclarationColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.DECLARATION); + } + + public void testStepIdUsesWorkflowDeclarationColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: package + run: echo ok + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.DECLARATION); + } + + public void testMixedStepNameTextDoesNotUseWorkflowVariableOrDeclarationColor() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: java_info + run: echo "project_version=1.0.0" >> "$GITHUB_OUTPUT" + - id: semver_info + run: echo "clean_semver=1.0.1" >> "$GITHUB_OUTPUT" + - name: "Update Project Version (Maven Only) [${{ steps.java_info.outputs.project_version }} > ${{ steps.semver_info.outputs.clean_semver }}]" + run: echo ok + """); + + assertNoTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + assertNoTextAttributeAtCurrentCaret(WorkflowAnnotator.DECLARATION); + } + + public void testMixedStepNameExpressionUsesWorkflowVariableColorOnlyInsideExpression() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: java_info + run: echo "project_version=1.0.0" >> "$GITHUB_OUTPUT" + - id: semver_info + run: echo "clean_semver=1.0.1" >> "$GITHUB_OUTPUT" + - name: "Update Project Version (Maven Only) [${{ steps.java_info.outputs.project_version }} > ${{ steps.semver_info.outputs.clean_semver }}]" + run: echo ok + """); + + assertTextAttributeAtCurrentCaret(WorkflowAnnotator.VARIABLE_REFERENCE); + } + + public void testWorkflowCallInputDefaultExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: + workflow_call: + inputs: + known-input: + type: string + target: + type: string + default: ${{ inputs.known-input }} + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testEnvExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + env: + TOP_LEVEL: top + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo "${{ env.TOP_LEVEL }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testJobEnvMapAliasExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + define: + runs-on: ubuntu-latest + env: &env_vars + NODE_ENV: production + steps: + - run: echo ok + reuse: + runs-on: ubuntu-latest + env: *env_vars + steps: + - run: echo "${{ env.NODE_ENV }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testMatrixExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + steps: + - run: echo "${{ matrix.os }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testStepOutputExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - id: package + run: echo "artifact=dist" >> "$GITHUB_OUTPUT" + - run: echo "${{ steps.package.outputs.artifact }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testNeedsScalarIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo ok + test: + needs: build + runs-on: ubuntu-latest + steps: + - run: echo ok + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testJobServiceExpressionIsStyledAsReference() { + configureWorkflowProjectFile(""" + name: Styling + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + steps: + - run: echo "${{ job.services.postgres.network }}" + """); + + assertHighlightedReferenceAtCurrentCaret(); + } + + public void testRunBlockInjectsShellScriptLanguageWhenAvailable() { + assertThat(Language.findLanguageByID("Shell Script")).isNotNull(); + + configureWorkflowProjectFile(""" + name: Injection + on: workflow_dispatch + jobs: + build: + runs-on: ubuntu-latest + steps: + - shell: bash + run: | + echo "hello" + if [ -f pom.xml ]; then + echo ok + fi + """); + + final YAMLScalar scalar = scalarAtCaret(); + final List> injected = InjectedLanguageManager.getInstance(getProject()).getInjectedPsiFiles(scalar); + + assertThat(injected).isNotEmpty(); + assertThat(injected.get(0).first.getLanguage().getID()).isEqualTo("Shell Script"); + } + + private YAMLScalar scalarAtCaret() { + final int offset = myFixture.getCaretOffset(); + final YAMLScalar scalar = PsiTreeUtil.findChildrenOfType(myFixture.getFile(), YAMLScalar.class) + .stream() + .filter(candidate -> candidate.getTextRange().getStartOffset() <= offset) + .filter(candidate -> offset <= candidate.getTextRange().getEndOffset()) + .findFirst() + .orElse(null); + assertThat(scalar).isNotNull(); + return scalar; + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfigurationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfigurationTest.java new file mode 100644 index 0000000..ee60388 --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunConfigurationTest.java @@ -0,0 +1,86 @@ +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowRunConfigurationTest extends EditorFeatureTestCase { + + public void testResetUsesOnlyTheSelectedConfigurationInputs() throws Exception { + final WorkflowRunConfiguration.Editor editor = new WorkflowRunConfiguration.Editor(); + final WorkflowRunConfiguration first = configuration("first").inputsText("old_key=old-value\n"); + final WorkflowRunConfiguration second = configuration("second").inputsText("new_key=new-value\n"); + + editor.resetFrom(first); + editor.applyTo(first); + editor.resetFrom(second); + editor.applyTo(second); + + assertThat(second.toRequest().inputs()) + .containsEntry("new_key", "new-value") + .doesNotContainKey("old_key"); + } + + public void testParseDispatchInputsWithDefaults() { + final WorkflowRun.DispatchInputs inputs = new WorkflowRun.DispatchInputs(); + + assertThat(inputs.parse(""" + name: Dispatch + on: + workflow_dispatch: + inputs: + ref: + description: Branch + type: string + required: true + default: main + dry_run: + type: boolean + default: "true" + environment: + description: Target + type: choice + options: + - dev + - prod + jobs: + build: + runs-on: ubuntu-latest + """)).containsExactly( + new WorkflowRun.DispatchInputs.Input("ref", "string", true, "main", "Branch"), + new WorkflowRun.DispatchInputs.Input("dry_run", "boolean", false, "true", ""), + new WorkflowRun.DispatchInputs.Input("environment", "choice", false, "", "Target", List.of("dev", "prod")) + ); + } + + public void testDefaultsTextBuildsPlainKeyValueLines() { + final WorkflowRun.DispatchInputs inputs = new WorkflowRun.DispatchInputs(); + + assertThat(inputs.defaultsText(""" + on: + workflow_dispatch: + inputs: + ref: + description: Branch + type: choice + required: true + default: main + options: [main, "release, candidate"] + """)).isEqualTo("ref=main\n"); + } + + public void testKeyValueInputTextIgnoresCommentsAndBlankLines() { + assertThat(WorkflowRun.DispatchInputs.parseKeyValueText(""" + # ignored + ref=main + + dry_run=true + """)).containsEntry("ref", "main").containsEntry("dry_run", "true"); + } + + private WorkflowRunConfiguration configuration(final String name) { + return new WorkflowRunConfiguration(getProject(), WorkflowRunConfiguration.Type.getInstance().factory(), name); + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandlerTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java similarity index 92% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandlerTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java index 123c14d..5529a5e 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunProcessHandlerTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunProcessHandlerTest.java @@ -1,4 +1,6 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; import com.intellij.execution.process.ProcessEvent; import com.intellij.execution.process.ProcessListener; @@ -28,11 +30,11 @@ public void testProcessStreamsJobLogDeltasWithoutAuthStrategyNoise() throws Exce final AtomicInteger statusCalls = new AtomicInteger(0); final AtomicInteger jobCalls = new AtomicInteger(0); final AtomicInteger logCalls = new AtomicInteger(0); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> responseFor(request, statusCalls, jobCalls, logCalls), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -87,11 +89,11 @@ public void testDestroyCancelsRemoteRunAndTerminates() throws Exception { final CountDownLatch statusSeen = new CountDownLatch(1); final CountDownLatch cancelSeen = new CountDownLatch(1); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> cancellationResponseFor(request, statusSeen, cancelSeen), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -134,11 +136,11 @@ public void processTerminated(@NotNull final ProcessEvent event) { public void testDeleteRemoteRunUsesCompletedRunIdAndReportsToWorkflowConsole() throws Exception { final CountDownLatch deleteSeen = new CountDownLatch(1); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> completedRunWithDeleteResponseFor(request, deleteSeen), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -178,11 +180,11 @@ public void testRerunRemoteRunUsesCompletedRunIdAndReportsToWorkflowConsole() th final CountDownLatch rerunAllSeen = new CountDownLatch(1); final CountDownLatch rerunFailedSeen = new CountDownLatch(1); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> completedRunWithRerunResponseFor(request, rerunAllSeen, rerunFailedSeen), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -221,11 +223,11 @@ public void processTerminated(@NotNull final ProcessEvent event) { public void testProcessRoutesEachJobLogToSeparateJobConsole() throws Exception { final AtomicInteger statusCalls = new AtomicInteger(0); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> multiJobResponseFor(request, statusCalls), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -275,11 +277,11 @@ public void testProcessDefersLiveLogPermissionFailuresUntilFinalLogIsAvailable() final AtomicInteger statusCalls = new AtomicInteger(0); final AtomicInteger jobCalls = new AtomicInteger(0); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> adminLiveLogResponseFor(request, statusCalls, jobCalls), - request -> List.of(new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token")) + request -> List.of(new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token")) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -323,11 +325,11 @@ public void testProcessFetchesCompletedJobLogAfterLiveFailureBeforeRunCompletes( final AtomicInteger jobCalls = new AtomicInteger(0); final AtomicInteger completedLogFetchedAtStatusCall = new AtomicInteger(-1); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> completedJobLogAfterLiveFailureResponseFor(request, statusCalls, jobCalls, completedLogFetchedAtStatusCall), - request -> List.of(new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token")) + request -> List.of(new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token")) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -371,11 +373,11 @@ public void testProcessUsesEnterpriseWorkflowUrlInDispatchMessage() throws Excep final AtomicInteger statusCalls = new AtomicInteger(0); final AtomicInteger jobCalls = new AtomicInteger(0); final AtomicInteger logCalls = new AtomicInteger(0); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> responseFor(request, statusCalls, jobCalls, logCalls), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://github.acme.test/api/v3", "tools", "workflow-box", @@ -417,11 +419,11 @@ public void testProcessRetriesLiveLogAfterDeferredHttpFailure() throws Exception final AtomicInteger statusCalls = new AtomicInteger(0); final AtomicInteger logCalls = new AtomicInteger(0); final CapturingJobConsole jobConsole = new CapturingJobConsole(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> retryableLiveLogResponseFor(request, statusCalls, logCalls), - request -> List.of(new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token")) + request -> List.of(new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token")) ); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun.Request request = new WorkflowRun.Request( "https://api.github.test", "acme", "tool", @@ -734,7 +736,7 @@ public Optional sslSession() { } } - private static final class CapturingJobConsole implements WorkflowRunJobConsole { + private static class CapturingJobConsole implements WorkflowRunProcessHandler.JobConsole { private final Object lock = new Object(); private final Map output = new HashMap<>(); private final StringBuilder workflowOutput = new StringBuilder(); @@ -742,26 +744,26 @@ private static final class CapturingJobConsole implements WorkflowRunJobConsole private final java.util.ArrayList deleted = new java.util.ArrayList<>(); @Override - public boolean jobStatus(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStatus(final WorkflowRun.JobStatus job, final String text) { append(job, text); return true; } @Override - public boolean jobStdout(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStdout(final WorkflowRun.JobStatus job, final String text) { append(job, text); return true; } @Override - public boolean jobStderr(final WorkflowRunClient.JobStatus job, final String text) { + public boolean jobStderr(final WorkflowRun.JobStatus job, final String text) { append(job, text); return true; } @Override - public boolean jobLog(final WorkflowRunClient.JobStatus job, final String text) { - WorkflowRunLogRenderer.renderOnce(text).forEach(segment -> append(job, segment.text())); + public boolean jobLog(final WorkflowRun.JobStatus job, final String text) { + WorkflowRunView.LogRenderer.renderOnce(text).forEach(segment -> append(job, segment.text())); return true; } @@ -786,7 +788,7 @@ public void runDeleted(final long runId) { } } - private void append(final WorkflowRunClient.JobStatus job, final String text) { + private void append(final WorkflowRun.JobStatus job, final String text) { synchronized (lock) { output.computeIfAbsent(job.id(), ignored -> new StringBuilder()).append(text); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClientTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java similarity index 75% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClientTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java index c5b7b62..8173d95 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunClientTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunTest.java @@ -1,4 +1,6 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; import com.sun.net.httpserver.HttpServer; import junit.framework.TestCase; @@ -23,12 +25,12 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThat; -public class WorkflowRunClientTest extends TestCase { +public class WorkflowRunTest extends TestCase { public void testDispatchPostsWorkflowDispatchRequest() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { - final WorkflowRunClient client = new WorkflowRunClient(); - final WorkflowRunRequest request = new WorkflowRunRequest( + final WorkflowRun client = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request( server.apiUrl(), "acme", "tool", @@ -38,7 +40,7 @@ public void testDispatchPostsWorkflowDispatchRequest() throws Exception { "" ); - final WorkflowRunClient.DispatchResult result = client.dispatch(request); + final WorkflowRun.DispatchResult result = client.dispatch(request); assertThat(result.runId()).isEqualTo(42); assertThat(server.requests()).contains("/repos/acme/tool/actions/workflows/build.yml/dispatches"); @@ -48,14 +50,14 @@ public void testDispatchPostsWorkflowDispatchRequest() throws Exception { public void testStatusCancelJobsAndLogsUseRunEndpoints() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { - final WorkflowRunClient client = new WorkflowRunClient(); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - - final WorkflowRunClient.RunStatus status = client.status(request, 42); - final WorkflowRunClient.CancelResult cancel = client.cancel(request, 42); - final WorkflowRunClient.RerunResult rerunAll = client.rerun(request, 42, false); - final WorkflowRunClient.RerunResult rerunFailed = client.rerun(request, 42, true); - final WorkflowRunClient.DeleteResult delete = client.delete(request, 42); + final WorkflowRun client = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); + + final WorkflowRun.RunStatus status = client.status(request, 42); + final WorkflowRun.CancelResult cancel = client.cancel(request, 42); + final WorkflowRun.RerunResult rerunAll = client.rerun(request, 42, false); + final WorkflowRun.RerunResult rerunFailed = client.rerun(request, 42, true); + final WorkflowRun.DeleteResult delete = client.delete(request, 42); final String logs = client.logs(request, 42); assertThat(status.completed()).isTrue(); @@ -79,10 +81,10 @@ public void testStatusCancelJobsAndLogsUseRunEndpoints() throws Exception { public void testDispatchAcceptsLegacyNoContentResponse() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer(true)) { - final WorkflowRunClient client = new WorkflowRunClient(); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun client = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - final WorkflowRunClient.DispatchResult result = client.dispatch(request); + final WorkflowRun.DispatchResult result = client.dispatch(request); assertThat(result.runId()).isEqualTo(-1); assertThat(result.htmlUrl()).isEmpty(); @@ -91,8 +93,8 @@ public void testDispatchAcceptsLegacyNoContentResponse() throws Exception { public void testLatestRunDiscoversNewestWorkflowDispatchRun() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { - final WorkflowRunClient client = new WorkflowRunClient(); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "feature/one", Map.of(), ""); + final WorkflowRun client = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "feature/one", Map.of(), ""); final var result = client.latestRun(request); @@ -106,13 +108,13 @@ public void testLatestRunDiscoversNewestWorkflowDispatchRun() throws Exception { public void testArtifactsAndZipUseRunArtifactEndpoints() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer()) { - final WorkflowRunClient client = new WorkflowRunClient(); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun client = new WorkflowRun(); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - final List artifacts = client.artifacts(request, 42); + final List artifacts = client.artifacts(request, 42); final byte[] zip = client.artifactZip(request, artifacts.get(0).id()); - assertThat(artifacts).containsExactly(new WorkflowRunClient.ArtifactStatus(300, "reports", 9, false, "artifact-url")); + assertThat(artifacts).containsExactly(new WorkflowRun.ArtifactStatus(300, "reports", 9, false, "artifact-url")); assertThat(new String(zip, StandardCharsets.UTF_8)).isEqualTo("zip-bytes"); assertThat(server.requests()).contains( "/repos/acme/tool/actions/runs/42/artifacts?per_page=100", @@ -124,17 +126,17 @@ public void testArtifactsAndZipUseRunArtifactEndpoints() throws Exception { public void testDispatchRetriesConfiguredAuthorizationsBeforeAnonymous() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer(false, 2)) { final HttpClient httpClient = HttpClient.newHttpClient(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)), request -> List.of( - new GitHubRequestAuthorizations.Authorization("github.com", "Bearer normal-token"), - new GitHubRequestAuthorizations.Authorization("enterprise", "Bearer enterprise-token"), - GitHubRequestAuthorizations.Authorization.anonymous() + new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer normal-token"), + new RemoteActionProviders.Authorizations.Authorization("enterprise", "Bearer enterprise-token"), + RemoteActionProviders.Authorizations.Authorization.anonymous() ) ); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - final WorkflowRunClient.DispatchResult result = client.dispatch(request); + final WorkflowRun.DispatchResult result = client.dispatch(request); assertThat(result.runId()).isEqualTo(42); assertThat(server.authorizations()).containsExactly("Bearer normal-token", "Bearer enterprise-token", ""); @@ -144,7 +146,7 @@ public void testDispatchRetriesConfiguredAuthorizationsBeforeAnonymous() throws public void testSuccessfulAuthorizationIsReusedWhenProviderLaterCannotLoadAccounts() throws Exception { final AtomicInteger providerCalls = new AtomicInteger(); final List authorizations = new ArrayList<>(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> { authorizations.add(authorizationHeader(request)); if (request.uri().getPath().endsWith("/dispatches")) { @@ -157,10 +159,10 @@ public void testSuccessfulAuthorizationIsReusedWhenProviderLaterCannotLoadAccoun return new ClientResponse(request, 403, "application/json", "{\"message\":\"API rate limit exceeded\"}"); }, request -> providerCalls.getAndIncrement() == 0 - ? List.of(new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token")) - : List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + ? List.of(new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token")) + : List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); client.dispatch(request); final String logs = client.jobLogs(request, 100); @@ -171,7 +173,7 @@ public void testSuccessfulAuthorizationIsReusedWhenProviderLaterCannotLoadAccoun public void testAuthenticatedRateLimitDoesNotFallBackToAnonymous() { final List authorizations = new ArrayList<>(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> { authorizations.add(authorizationHeader(request)); if (authorizationHeader(request).isEmpty()) { @@ -180,13 +182,13 @@ public void testAuthenticatedRateLimitDoesNotFallBackToAnonymous() { return new ClientResponse(request, 403, "application/json", "{\"message\":\"API rate limit exceeded for token\"}"); }, request -> List.of( - new GitHubRequestAuthorizations.Authorization("github.com", "Bearer limited-token"), - GitHubRequestAuthorizations.Authorization.anonymous() + new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer limited-token"), + RemoteActionProviders.Authorizations.Authorization.anonymous() ) ); - final WorkflowRunRequest request = new WorkflowRunRequest("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); - assertThatExceptionOfType(WorkflowRunClient.WorkflowRunHttpException.class) + assertThatExceptionOfType(WorkflowRun.WorkflowRunHttpException.class) .isThrownBy(() -> client.jobLogs(request, 100)) .withMessageContaining("GitHub workflow job logs failed with HTTP 403") .withMessageContaining("rate limit"); @@ -195,7 +197,7 @@ public void testAuthenticatedRateLimitDoesNotFallBackToAnonymous() { public void testJobLogsFallBackFromAccountTokenWithoutLogRightsToEnvironmentToken() throws Exception { final List authorizations = new ArrayList<>(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> { authorizations.add(authorizationHeader(request)); if ("Bearer env-token".equals(authorizationHeader(request))) { @@ -209,12 +211,12 @@ public void testJobLogsFallBackFromAccountTokenWithoutLogRightsToEnvironmentToke ); }, request -> List.of( - new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token"), - new GitHubRequestAuthorizations.Authorization("GITHUB_TOKEN", "Bearer env-token"), - GitHubRequestAuthorizations.Authorization.anonymous() + new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token"), + new RemoteActionProviders.Authorizations.Authorization("GITHUB_TOKEN", "Bearer env-token"), + RemoteActionProviders.Authorizations.Authorization.anonymous() ) ); - final WorkflowRunRequest request = new WorkflowRunRequest("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); final String logs = client.jobLogs(request, 100); @@ -223,18 +225,18 @@ public void testJobLogsFallBackFromAccountTokenWithoutLogRightsToEnvironmentToke } public void testJobLogAdminFailureDoesNotSuggestRefreshingAccounts() { - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> new ClientResponse( request, 403, "application/json", "{\"message\":\"Must have admin rights to Repository.\"}" ), - request -> List.of(new GitHubRequestAuthorizations.Authorization("github.com", "Bearer account-token")) + request -> List.of(new RemoteActionProviders.Authorizations.Authorization("github.com", "Bearer account-token")) ); - final WorkflowRunRequest request = new WorkflowRunRequest("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); - assertThatExceptionOfType(WorkflowRunClient.WorkflowRunHttpException.class) + assertThatExceptionOfType(WorkflowRun.WorkflowRunHttpException.class) .isThrownBy(() -> client.jobLogs(request, 100)) .withMessageContaining("GitHub workflow job logs failed with HTTP 403") .withMessageContaining("Must have admin rights") @@ -244,13 +246,13 @@ public void testJobLogAdminFailureDoesNotSuggestRefreshingAccounts() { public void testDispatchAuthenticationFailureMentionsGithubSettings() throws Exception { try (FakeWorkflowRunServer server = new FakeWorkflowRunServer(false, 1)) { final HttpClient httpClient = HttpClient.newHttpClient(); - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request(server.apiUrl(), "acme", "tool", "build.yml", "main", Map.of(), ""); - assertThatExceptionOfType(WorkflowRunClient.WorkflowRunHttpException.class) + assertThatExceptionOfType(WorkflowRun.WorkflowRunHttpException.class) .isThrownBy(() -> client.dispatch(request)) .withMessageContaining("GitHub workflow dispatch failed with HTTP 401") .withMessageContaining("Settings > Version Control > GitHub"); @@ -258,18 +260,18 @@ public void testDispatchAuthenticationFailureMentionsGithubSettings() throws Exc } public void testJobLogHtmlFailureIsSummarized() { - final WorkflowRunClient client = new WorkflowRunClient( + final WorkflowRun client = new WorkflowRun( request -> new ClientResponse( request, 504, "text/html", "

We couldn't respond in time.

" ), - request -> List.of(GitHubRequestAuthorizations.Authorization.anonymous()) + request -> List.of(RemoteActionProviders.Authorizations.Authorization.anonymous()) ); - final WorkflowRunRequest request = new WorkflowRunRequest("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); + final WorkflowRun.Request request = new WorkflowRun.Request("https://api.github.test", "acme", "tool", "build.yml", "main", Map.of(), ""); - assertThatExceptionOfType(WorkflowRunClient.WorkflowRunHttpException.class) + assertThatExceptionOfType(WorkflowRun.WorkflowRunHttpException.class) .isThrownBy(() -> client.jobLogs(request, 100)) .withMessageContaining("GitHub workflow job logs failed with HTTP 504") .withMessageContaining("GitHub returned an HTML error page") @@ -277,7 +279,7 @@ public void testJobLogHtmlFailureIsSummarized() { .withMessageNotContaining("base64"); } - private static final class FakeWorkflowRunServer implements AutoCloseable { + private static class FakeWorkflowRunServer implements AutoCloseable { private final HttpServer server; private final List requests = new ArrayList<>(); private final List methods = new ArrayList<>(); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRendererTest.java b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunViewTest.java similarity index 56% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRendererTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunViewTest.java index 83f3791..c6a262c 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLogRendererTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/run/WorkflowRunViewTest.java @@ -1,4 +1,4 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.run; import junit.framework.TestCase; @@ -6,31 +6,45 @@ import static org.assertj.core.api.Assertions.assertThat; -public class WorkflowRunLogRendererTest extends TestCase { +public class WorkflowRunViewTest extends TestCase { + + public void testMatrixStyleJobNameIsGroupedLikeJUnitClassAndMethod() { + final WorkflowRunView.JobDisplayName name = WorkflowRunView.splitJobName("Node Test / test (ubuntu-latest)"); + + assertThat(name.group()).isEqualTo("Node Test"); + assertThat(name.name()).isEqualTo("test (ubuntu-latest)"); + } + + public void testPlainJobNameStaysUnderWorkflowRoot() { + final WorkflowRunView.JobDisplayName name = WorkflowRunView.splitJobName("build"); + + assertThat(name.group()).isEmpty(); + assertThat(name.name()).isEqualTo("build"); + } public void testRenderStripsGithubTimestampAndFormatsGroupsAndCommands() { - final List segments = WorkflowRunLogRenderer.renderOnce(""" + final List segments = WorkflowRunView.LogRenderer.renderOnce(""" 2026-05-22T13:38:12.0538840Z ##[group]Run actions/checkout@main 2026-05-22T13:38:12.0539220Z ##[command]/usr/bin/git version 2026-05-22T13:38:12.0539420Z ##[/group] """); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::text) + .extracting(WorkflowRunView.LogRenderer.Segment::text) .containsExactly( "== Run actions/checkout@main ==\n", "0001 | run: /usr/bin/git version\n" ); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::kind) + .extracting(WorkflowRunView.LogRenderer.Segment::kind) .containsExactly( - WorkflowRunLogRenderer.Kind.SYSTEM, - WorkflowRunLogRenderer.Kind.SYSTEM + WorkflowRunView.LogRenderer.Kind.SYSTEM, + WorkflowRunView.LogRenderer.Kind.SYSTEM ); } public void testRenderClassifiesGithubWarningsAndErrors() { - final List segments = WorkflowRunLogRenderer.renderOnce(""" + final List segments = WorkflowRunView.LogRenderer.renderOnce(""" ##[warning]old input ##[error file=build.gradle,line=7]broken build ::warning::soft problem @@ -38,7 +52,7 @@ public void testRenderClassifiesGithubWarningsAndErrors() { """); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::text) + .extracting(WorkflowRunView.LogRenderer.Segment::text) .containsExactly( "0001 | warning: old input\n", "0002 | error: broken build\n", @@ -46,17 +60,17 @@ public void testRenderClassifiesGithubWarningsAndErrors() { "0004 | error: hard problem\n" ); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::kind) + .extracting(WorkflowRunView.LogRenderer.Segment::kind) .containsExactly( - WorkflowRunLogRenderer.Kind.WARNING, - WorkflowRunLogRenderer.Kind.ERROR, - WorkflowRunLogRenderer.Kind.WARNING, - WorkflowRunLogRenderer.Kind.ERROR + WorkflowRunView.LogRenderer.Kind.WARNING, + WorkflowRunView.LogRenderer.Kind.ERROR, + WorkflowRunView.LogRenderer.Kind.WARNING, + WorkflowRunView.LogRenderer.Kind.ERROR ); } public void testRenderInfersCommonWarningAndErrorPrefixes() { - final List segments = WorkflowRunLogRenderer.renderOnce(""" + final List segments = WorkflowRunView.LogRenderer.renderOnce(""" npm warn deprecated old-package warning: check this fatal: repository not found @@ -64,17 +78,17 @@ public void testRenderInfersCommonWarningAndErrorPrefixes() { """); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::kind) + .extracting(WorkflowRunView.LogRenderer.Segment::kind) .containsExactly( - WorkflowRunLogRenderer.Kind.WARNING, - WorkflowRunLogRenderer.Kind.WARNING, - WorkflowRunLogRenderer.Kind.ERROR, - WorkflowRunLogRenderer.Kind.NORMAL + WorkflowRunView.LogRenderer.Kind.WARNING, + WorkflowRunView.LogRenderer.Kind.WARNING, + WorkflowRunView.LogRenderer.Kind.ERROR, + WorkflowRunView.LogRenderer.Kind.NORMAL ); } public void testRenderPlainKeepsReadableTextOnly() { - assertThat(WorkflowRunLogRenderer.renderPlainOnce(""" + assertThat(WorkflowRunView.LogRenderer.renderPlainOnce(""" 2026-05-22T13:38:12.0538840Z ##[group]Install ##[command]npm ci ##[warning]deprecated @@ -87,7 +101,7 @@ public void testRenderPlainKeepsReadableTextOnly() { } public void testRenderKeepsGroupLineNumbersAcrossChunksAndResetsPerGroup() { - final WorkflowRunLogRenderer renderer = new WorkflowRunLogRenderer(); + final WorkflowRunView.LogRenderer renderer = new WorkflowRunView.LogRenderer(); assertThat(renderer.renderPlain(""" ##[group]Install @@ -112,25 +126,25 @@ public void testRenderKeepsGroupLineNumbersAcrossChunksAndResetsPerGroup() { } public void testRenderStripsAnsiAndMapsCommonColors() { - final List segments = WorkflowRunLogRenderer.renderOnce(""" + final List segments = WorkflowRunView.LogRenderer.renderOnce(""" \u001B[36;1mnpm ci && npm run test\u001B[0m \u001B[33mcareful\u001B[0m \u001B[31mboom\u001B[0m """); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::text) + .extracting(WorkflowRunView.LogRenderer.Segment::text) .containsExactly( "0001 | npm ci && npm run test\n", "0002 | careful\n", "0003 | boom\n" ); assertThat(segments) - .extracting(WorkflowRunLogRenderer.Segment::kind) + .extracting(WorkflowRunView.LogRenderer.Segment::kind) .containsExactly( - WorkflowRunLogRenderer.Kind.SYSTEM, - WorkflowRunLogRenderer.Kind.WARNING, - WorkflowRunLogRenderer.Kind.ERROR + WorkflowRunView.LogRenderer.Kind.SYSTEM, + WorkflowRunView.LogRenderer.Kind.WARNING, + WorkflowRunView.LogRenderer.Kind.ERROR ); } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizationsTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizationsTest.java deleted file mode 100644 index c8a71f7..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/GitHubRequestAuthorizationsTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import junit.framework.TestCase; - -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class GitHubRequestAuthorizationsTest extends TestCase { - - public void testStandardEnvironmentTokensAreTriedBeforeAnonymous() { - final List authorizations = GitHubRequestAuthorizations.forApiUrl( - "https://api.example.test", - "", - null, - Map.of("GITHUB_TOKEN", "env-token") - ); - - assertThat(authorizations) - .extracting(GitHubRequestAuthorizations.Authorization::source) - .containsSubsequence("GITHUB_TOKEN", "anonymous"); - assertThat(authorizations) - .extracting(GitHubRequestAuthorizations.Authorization::authorizationHeader) - .contains("Bearer env-token"); - } - - public void testExplicitEnvironmentTokenIsTriedBeforeStandardEnvironmentTokens() { - final List authorizations = GitHubRequestAuthorizations.forApiUrl( - "https://github.acme.test/api/v3", - "ACME_GITHUB_TOKEN", - null, - Map.of("ACME_GITHUB_TOKEN", "enterprise-token", "GITHUB_TOKEN", "default-token") - ); - - assertThat(authorizations) - .extracting(GitHubRequestAuthorizations.Authorization::source) - .containsSubsequence("ACME_GITHUB_TOKEN", "GITHUB_TOKEN", "anonymous"); - } - - public void testMissingEnvironmentTokensFallBackToAnonymous() { - final List authorizations = GitHubRequestAuthorizations.forApiUrl( - "https://github.acme.test/api/v3", - "ACME_GITHUB_TOKEN", - null, - Map.of() - ); - - assertThat(authorizations) - .extracting(GitHubRequestAuthorizations.Authorization::source) - .containsExactly("anonymous"); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitterTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitterTest.java deleted file mode 100644 index 37b357a..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/PluginErrorReportSubmitterTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.openapi.diagnostic.IdeaLoggingEvent; -import com.intellij.openapi.diagnostic.SubmittedReportInfo; -import org.junit.Test; - -import javax.swing.*; -import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; - -public class PluginErrorReportSubmitterTest { - - @Test - public void submitEmptyEventArrayFailsExplicitly() { - final PluginErrorReportSubmitter submitter = new PluginErrorReportSubmitter(); - final AtomicReference reportInfo = new AtomicReference<>(); - - final boolean submitted = submitter.submit(new IdeaLoggingEvent[0], "", new JPanel(), reportInfo::set); - - assertThat(submitted).isFalse(); - assertThat(reportInfo.get()).isNotNull(); - assertThat(reportInfo.get().getStatus()).isEqualTo(SubmittedReportInfo.SubmissionStatus.FAILED); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/SchemaResourcesTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/SchemaResourcesTest.java deleted file mode 100644 index 9e11681..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/SchemaResourcesTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import org.junit.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -public class SchemaResourcesTest { - - private static final List SCHEMA_NAMES = List.of( - "dependabot-2.0", - "github-action", - "github-funding", - "github-workflow", - "github-discussion", - "github-issue-forms", - "github-issue-config", - "github-workflow-template-properties" - ); - - @Test - public void packagedSchemasArePresentAndNonEmpty() throws IOException { - final Path directory = Path.of(System.getProperty("user.dir"), "src", "main", "resources", "schemas"); - - for (final String schemaName : SCHEMA_NAMES) { - final Path schema = directory.resolve(schemaName + ".json"); - assertThat(schema).exists().isRegularFile(); - assertThat(Files.readString(schema)) - .startsWith("{") - .contains("\"$schema\"") - .contains("\"$id\""); - } - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolverTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolverTest.java deleted file mode 100644 index 58696d4..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCurrentBranchResolverTest.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import junit.framework.TestCase; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowCurrentBranchResolverTest extends TestCase { - - public void testBranchNameReadsRefsHeadsBranch() { - assertThat(WorkflowCurrentBranchResolver.branchName("ref: refs/heads/feature/logs\n")) - .contains("feature/logs"); - } - - public void testBranchNameIgnoresDetachedHead() { - assertThat(WorkflowCurrentBranchResolver.branchName("e1a9e573f4d0838b3a7c1b07401aeb29ed3635a9")) - .isEmpty(); - } - - public void testResolveReadsCurrentBranchFromGitHead() throws Exception { - final Path dir = Files.createTempDirectory("workflow-branch"); - Files.createDirectories(dir.resolve(".git")); - Files.writeString(dir.resolve(".git").resolve("HEAD"), "ref: refs/heads/feature/current\n"); - - assertThat(new WorkflowCurrentBranchResolver().resolve(dir)) - .contains("feature/current"); - } - - public void testResolveReadsCurrentBranchFromWorktreeGitFile() throws Exception { - final Path dir = Files.createTempDirectory("workflow-worktree"); - final Path gitDir = Files.createDirectories(dir.resolve("real-git-dir")); - Files.writeString(dir.resolve(".git"), "gitdir: real-git-dir\n"); - Files.writeString(gitDir.resolve("HEAD"), "ref: refs/heads/worktree/current\n"); - - assertThat(new WorkflowCurrentBranchResolver().resolve(dir)) - .contains("worktree/current"); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputsTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputsTest.java deleted file mode 100644 index d9ca479..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDispatchInputsTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import junit.framework.TestCase; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowDispatchInputsTest extends TestCase { - - public void testParseWorkflowDispatchInputsWithDefaults() { - final WorkflowDispatchInputs inputs = new WorkflowDispatchInputs(); - - assertThat(inputs.parse(""" - name: Dispatch - on: - workflow_dispatch: - inputs: - ref: - description: Branch - type: string - required: true - default: main - dry_run: - type: boolean - default: "true" - environment: - description: Target - type: choice - options: - - dev - - prod - jobs: - build: - runs-on: ubuntu-latest - """)).containsExactly( - new WorkflowDispatchInputs.Input("ref", "string", true, "main", "Branch"), - new WorkflowDispatchInputs.Input("dry_run", "boolean", false, "true", ""), - new WorkflowDispatchInputs.Input("environment", "choice", false, "", "Target", java.util.List.of("dev", "prod")) - ); - } - - public void testDefaultsTextBuildsPlainKeyValueLines() { - final WorkflowDispatchInputs inputs = new WorkflowDispatchInputs(); - - assertThat(inputs.defaultsText(""" - on: - workflow_dispatch: - inputs: - ref: - description: Branch - type: choice - required: true - default: main - options: [main, "release, candidate"] - """)).isEqualTo("ref=main\n"); - } - - public void testKeyValueInputTextIgnoresCommentsAndBlankLines() { - assertThat(WorkflowDispatchInputs.parseKeyValueText(""" - # ignored - ref=main - - dry_run=true - """)).containsEntry("ref", "main").containsEntry("dry_run", "true"); - } - -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationTest.java deleted file mode 100644 index 18088cd..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowDocumentationTest.java +++ /dev/null @@ -1,288 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.model.GitHubAction; -import com.intellij.psi.PsiElement; - -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowDocumentationTest extends EditorFeatureTestCase { - - public void testUsesDocumentationShowsResolvedActionMetadata() { - final GitHubAction action = seedRemoteAction( - "actions/setup-java@v4", - Map.of("distribution", "Description: Java distribution\nRequired: true\nDefault: temurin"), - Map.of("cache-hit", "Description: Whether cache was restored") - ); - action.displayName("Setup Java") - .description("Set up a specific version of Java and add it to PATH."); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-java@v4 - """); - - assertThat(documentationHintAtCaret()) - .contains("Setup Java") - .contains("actions/setup-java@v4"); - } - - public void testInputVariableDocumentationShowsMetadata() { - configureWorkflowProjectFile(""" - name: Docs - on: - workflow_dispatch: - inputs: - tag: - description: Release tag - required: true - type: string - default: v1 - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ inputs.tag }}" - """); - - assertThat(documentationHintAtCaret()) - .contains("Input tag") - .contains("Description: Release tag") - .contains("Type: string") - .contains("Required: true") - .contains("Default: v1"); - } - - public void testActionInputDocumentationShowsResolvedActionParameter() { - seedRemoteAction( - "actions/setup-java@v4", - Map.of("distribution", "Description: Java distribution\nRequired: true\nDefault: temurin"), - Map.of() - ); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/setup-java@v4 - with: - distribution: temurin - """); - - assertThat(documentationHintAtCaret()) - .contains("Input distribution") - .contains("Java distribution") - .contains("Required: true") - .contains("Default: temurin"); - } - - public void testActionInputHoverDocumentationShowsResolvedActionParameter() { - seedRemoteAction( - "actions/checkout@v4", - Map.of("fetch-depth", "Description: Number of commits to fetch\nDefault: 1"), - Map.of() - ); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 500 - """); - - assertThat(documentationHtmlAtCaret()) - .contains("Input") - .contains("fetch-depth") - .contains("Number of commits to fetch") - .contains("Default"); - } - - public void testStepOutputDocumentationShowsOutputName() { - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: java_info - run: echo "is_gradle=true" >> "$GITHUB_OUTPUT" - - run: echo "${{ steps.java_info.outputs.is_gradle }}" - """); - - assertThat(documentationHintAtCaret()).contains("Step output is_gradle"); - } - - public void testActionOutputDocumentationShowsResolvedDescription() { - seedRemoteAction("owner/tool@v1", Map.of(), Map.of("artifact", "Description: Artifact path")); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: package - uses: owner/tool@v1 - - run: echo "${{ steps.package.outputs.artifact }}" - """); - - assertThat(documentationHintAtCaret()) - .contains("Step output artifact") - .contains("Description: Artifact path"); - } - - public void testActionOutputDocumentationShowsSourceStepAndActionLink() { - final GitHubAction action = seedRemoteAction( - "YunaBraska/java-info-action@main", - Map.of(), - Map.of("project_version", "Description: Project version\nType: string") - ); - action.displayName("Java Info").description("Reads Java metadata."); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - tag: - runs-on: ubuntu-latest - steps: - - name: Read Java Info - id: java_info - uses: YunaBraska/java-info-action@main - - run: echo "${{ steps.java_info.outputs.project_version }}" - """); - - assertThat(documentationHintAtCaret()) - .contains("Step output project_version") - .contains("Description: Project version") - .contains("Step: Read Java Info (java_info)") - .contains("Uses: YunaBraska/java-info-action@main") - .contains("External action: Java Info - Reads Java metadata."); - assertThat(documentationHtmlAtCaret()) - .contains("href=\"https://github.com/YunaBraska/java-info-action/tree/main#readme\"") - .contains(">YunaBraska/java-info-action@main"); - } - - public void testJobOutputDocumentationShowsMappedStepActionOutput() { - final GitHubAction action = seedRemoteAction( - "YunaBraska/java-info-action@main", - Map.of(), - Map.of("java_version", "Description: Java version\nType: string") - ); - action.displayName("Java Info").description("Reads Java metadata."); - - configureWorkflowProjectFile(""" - name: Docs - on: - workflow_call: - outputs: - java_version: - description: "[String] java version from pom file" - value: ${{ jobs.tag.outputs.java_version }} - jobs: - tag: - runs-on: ubuntu-latest - outputs: - java_version: ${{ steps.java_info.outputs.java_version }} - steps: - - name: Read Java Info - id: java_info - uses: YunaBraska/java-info-action@main - """); - - assertThat(documentationHintAtCaret()) - .contains("Reusable workflow job output java_version") - .contains("Java Info") - .contains("Reads Java metadata"); - } - - public void testStepDocumentationShowsResolvedActionNameAndDescription() { - final GitHubAction action = seedRemoteAction("YunaBraska/java-info-action@main", Map.of(), Map.of()); - action.displayName("Java Info").description("Reads Java metadata."); - - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - tag: - runs-on: ubuntu-latest - steps: - - name: Read Java Info - id: java_info - uses: YunaBraska/java-info-action@main - - run: echo "${{ steps.java_info.outputs.java_version }}" - """); - - assertThat(documentationHtmlAtCaret()) - .contains("Step") - .contains("Read Java Info") - .contains("Java Info") - .contains("Reads Java metadata"); - } - - public void testExpressionContextDocumentationShowsCollectionMeaning() { - configureWorkflowProjectFile(""" - name: Docs - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: java_info - run: echo "is_gradle=true" >> "$GITHUB_OUTPUT" - - run: echo "${{ steps.java_info.outputs.is_gradle }}" - """); - - assertThat(documentationHintAtCaret()).contains("Output values exposed"); - } - - private String documentationHintAtCaret() { - final WorkflowDocumentationProvider provider = new WorkflowDocumentationProvider(); - final PsiElement context = elementAtCaret(); - final PsiElement target = provider.getCustomDocumentationElement( - myFixture.getEditor(), - myFixture.getFile(), - context, - myFixture.getCaretOffset() - ); - assertThat(target).isNotNull(); - return provider.getQuickNavigateInfo(target, context); - } - - private String documentationHtmlAtCaret() { - final WorkflowDocumentationProvider provider = new WorkflowDocumentationProvider(); - final PsiElement context = elementAtCaret(); - final PsiElement target = provider.getCustomDocumentationElement( - myFixture.getEditor(), - myFixture.getFile(), - context, - myFixture.getCaretOffset() - ); - assertThat(target).isNotNull(); - return provider.generateHoverDoc(target, context); - } - - private PsiElement elementAtCaret() { - final PsiElement element = myFixture.getFile().findElementAt(myFixture.getCaretOffset()); - if (element != null) { - return element; - } - return myFixture.getFile().findElementAt(Math.max(0, myFixture.getCaretOffset() - 1)); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowPerformanceTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowPerformanceTest.java deleted file mode 100644 index d4b866a..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowPerformanceTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowPerformanceTest extends EditorFeatureTestCase { - - public void testLargeWorkflowHighlightingCompletesWithinBoundedTime() { - configureWorkflowProjectFile(largeWorkflow()); - - final long started = System.nanoTime(); - myFixture.doHighlighting(); - final long elapsedMillis = (System.nanoTime() - started) / 1_000_000L; - - assertThat(elapsedMillis).isLessThan(10_000L); - } - - private static String largeWorkflow() { - final StringBuilder workflow = new StringBuilder(""" - name: Large Workflow - on: - workflow_call: - inputs: - deploy-target: - type: string - env: - TOP_LEVEL: production - jobs: - """); - for (int job = 0; job < 40; job++) { - workflow.append(" build_").append(job).append(":\n"); - if (job > 0) { - workflow.append(" needs: build_").append(job - 1).append("\n"); - } - workflow.append(""" - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - outputs: - artifact: ${{ steps.package.outputs.artifact }} - steps: - - id: package - run: echo "artifact=dist" >> "$GITHUB_OUTPUT" - - run: echo "${{ inputs.deploy-target }} ${{ env.TOP_LEVEL }} ${{ matrix.os }} ${{ steps.package.outputs.artifact }}" - """); - } - return workflow.toString(); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolverTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolverTest.java deleted file mode 100644 index 5e222d3..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRepositoryResolverTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import junit.framework.TestCase; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowRepositoryResolverTest extends TestCase { - - public void testGithubHttpsRemoteUsesPublicApi() { - assertThat(WorkflowRepositoryResolver.fromRemoteUrl("https://github.com/YunaBraska/github-workflow-plugin.git")) - .contains(new WorkflowRepository( - "https://github.com", - "https://api.github.com", - "YunaBraska", - "github-workflow-plugin" - )); - } - - public void testEnterpriseHttpsRemoteUsesApiV3() { - assertThat(WorkflowRepositoryResolver.fromRemoteUrl("https://github.acme.test/tools/workflows.git")) - .contains(new WorkflowRepository( - "https://github.acme.test", - "https://github.acme.test/api/v3", - "tools", - "workflows" - )); - } - - public void testSshRemoteUsesPublicApi() { - assertThat(WorkflowRepositoryResolver.fromRemoteUrl("git@github.com:YunaBraska/github-workflow-plugin.git")) - .contains(new WorkflowRepository( - "https://github.com", - "https://api.github.com", - "YunaBraska", - "github-workflow-plugin" - )); - } - - public void testResolveReadsOriginFromGitConfig() throws Exception { - final Path dir = Files.createTempDirectory("workflow-repo"); - Files.createDirectories(dir.resolve(".git")); - Files.writeString(dir.resolve(".git").resolve("config"), """ - [remote "origin"] - url = https://github.com/YunaBraska/github-workflow-plugin.git - """); - - assertThat(new WorkflowRepositoryResolver().resolve(dir)) - .contains(new WorkflowRepository( - "https://github.com", - "https://api.github.com", - "YunaBraska", - "github-workflow-plugin" - )); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabsTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabsTest.java deleted file mode 100644 index 4263a42..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunConsoleTabsTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import junit.framework.TestCase; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowRunConsoleTabsTest extends TestCase { - - public void testMatrixStyleJobNameIsGroupedLikeJUnitClassAndMethod() { - final WorkflowRunConsoleTabs.JobDisplayName name = WorkflowRunConsoleTabs.splitJobName("Node Test / test (ubuntu-latest)"); - - assertThat(name.group()).isEqualTo("Node Test"); - assertThat(name.name()).isEqualTo("test (ubuntu-latest)"); - } - - public void testPlainJobNameStaysUnderWorkflowRoot() { - final WorkflowRunConsoleTabs.JobDisplayName name = WorkflowRunConsoleTabs.splitJobName("build"); - - assertThat(name.group()).isEmpty(); - assertThat(name.name()).isEqualTo("build"); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjectionTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjectionTest.java deleted file mode 100644 index 2f7415b..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunLanguageInjectionTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.intellij.lang.Language; -import com.intellij.lang.injection.InjectedLanguageManager; -import com.intellij.openapi.util.Pair; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiElement; -import com.intellij.psi.util.PsiTreeUtil; -import org.jetbrains.yaml.psi.YAMLScalar; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowRunLanguageInjectionTest extends EditorFeatureTestCase { - - public void testRunBlockInjectsShellScriptLanguageWhenAvailable() { - assertThat(Language.findLanguageByID("Shell Script")).isNotNull(); - - configureWorkflowProjectFile(""" - name: Injection - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - shell: bash - run: | - echo "hello" - if [ -f pom.xml ]; then - echo ok - fi - """); - - final YAMLScalar scalar = scalarAtCaret(); - final List> injected = InjectedLanguageManager.getInstance(getProject()).getInjectedPsiFiles(scalar); - - assertThat(injected).isNotEmpty(); - assertThat(injected.get(0).first.getLanguage().getID()).isEqualTo("Shell Script"); - } - - private YAMLScalar scalarAtCaret() { - final int offset = myFixture.getCaretOffset(); - final YAMLScalar scalar = PsiTreeUtil.findChildrenOfType(myFixture.getFile(), YAMLScalar.class) - .stream() - .filter(candidate -> candidate.getTextRange().getStartOffset() <= offset) - .filter(candidate -> offset <= candidate.getTextRange().getEndOffset()) - .findFirst() - .orElse(null); - assertThat(scalar).isNotNull(); - return scalar; - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditorTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditorTest.java deleted file mode 100644 index 63ada58..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowRunSettingsEditorTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import static org.assertj.core.api.Assertions.assertThat; - -public class WorkflowRunSettingsEditorTest extends EditorFeatureTestCase { - - public void testResetUsesOnlyTheSelectedConfigurationInputs() throws Exception { - final WorkflowRunSettingsEditor editor = new WorkflowRunSettingsEditor(); - final WorkflowRunConfiguration first = configuration("first").inputsText("old_key=old-value\n"); - final WorkflowRunConfiguration second = configuration("second").inputsText("new_key=new-value\n"); - - editor.resetFrom(first); - editor.applyTo(first); - editor.resetFrom(second); - editor.applyTo(second); - - assertThat(second.toRequest().inputs()) - .containsEntry("new_key", "new-value") - .doesNotContainKey("old_key"); - } - - private WorkflowRunConfiguration configuration(final String name) { - return new WorkflowRunConfiguration(getProject(), WorkflowRunConfigurationType.getInstance().factory(), name); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowStylingTest.java b/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowStylingTest.java deleted file mode 100644 index 2999875..0000000 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowStylingTest.java +++ /dev/null @@ -1,404 +0,0 @@ -package com.github.yunabraska.githubworkflow.services; - -import com.github.yunabraska.githubworkflow.model.GitHubAction; - -import java.util.List; -import java.util.Map; - -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; - -public class WorkflowStylingTest extends EditorFeatureTestCase { - - public void testResolvedRemoteActionUseIsStyledAsReference() { - seedRemoteAction("owner/tool@v1", Map.of(), Map.of()); - - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: owner/tool@v1 - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testConfiguredGithubEnterpriseRemoteActionUseIsStyledAsReference() throws Exception { - try (FakeRemoteServer server = new FakeRemoteServer()) { - server.addContent("acme", "tool", "action.yml", "main", """ - name: Enterprise Tool - runs: - using: composite - steps: - - run: echo ok - shell: sh - """); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server("Fake Enterprise", - server.webUrl(), - server.apiUrl("/api/v3"), - "", - true - ))); - final String usesValue = server.webUrl() + "/acme/tool@main"; - final GitHubAction action = GitHubAction.createGithubAction(false, usesValue, usesValue).resolve(); - getActionCache().getState().actions.put(usesValue, action); - - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: %s/acme/tool@main - """.formatted(server.webUrl())); - - assertHighlightedReferenceAtCurrentCaret(); - } - } - - public void testResolvedLocalWorkflowUseIsStyledAsReference() { - seedLocalAction("./.github/workflows/reusable.yml", myFixture.addFileToProject(".github/workflows/reusable.yml", """ - name: Reusable - on: workflow_call - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo ok - """)); - - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - call: - uses: ./.github/workflows/reusable.yml - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testWorkflowInputExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: - workflow_call: - inputs: - known-input: - type: string - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ inputs.known-input }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testWorkflowInputExpressionUsesWorkflowVariableColor() { - configureWorkflowProjectFile(""" - name: Styling - on: - workflow_call: - inputs: - known-input: - type: string - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ inputs.known-input }}" - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - } - - public void testUnresolvedExpressionContextSegmentUsesWorkflowVariableColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ steps.pom.outputs.has_pom }}" - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - } - - public void testAutomaticGithubTokenSecretUsesWorkflowVariableColorWithoutReferenceRequirement() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ secrets.GITHUB_TOKEN }}" - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - } - - public void testIfExpressionWithoutBracesUsesWorkflowVariableColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - run: echo ok - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - } - - public void testRunCommandGithubOutputUsesRunnerVariableColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "java_version=21" >> "$GITHUB_OUTPUT" - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.RUNNER_VARIABLE); - } - - public void testBooleanAndNumberScalarsUseLiteralColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: owner/tool@v1 - with: - generateReleaseNotes: true - fetch-depth: 500 - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.SCALAR_LITERAL); - } - - public void testNumberScalarsUseLiteralColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: owner/tool@v1 - with: - fetch-depth: 500 - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.SCALAR_LITERAL); - } - - public void testJobIdUsesWorkflowDeclarationColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo ok - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.DECLARATION); - } - - public void testStepIdUsesWorkflowDeclarationColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: package - run: echo ok - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.DECLARATION); - } - - public void testMixedStepNameTextDoesNotUseWorkflowVariableOrDeclarationColor() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: java_info - run: echo "project_version=1.0.0" >> "$GITHUB_OUTPUT" - - id: semver_info - run: echo "clean_semver=1.0.1" >> "$GITHUB_OUTPUT" - - name: "Update Project Version (Maven Only) [${{ steps.java_info.outputs.project_version }} > ${{ steps.semver_info.outputs.clean_semver }}]" - run: echo ok - """); - - assertNoTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - assertNoTextAttributeAtCurrentCaret(WorkflowTextAttributes.DECLARATION); - } - - public void testMixedStepNameExpressionUsesWorkflowVariableColorOnlyInsideExpression() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: java_info - run: echo "project_version=1.0.0" >> "$GITHUB_OUTPUT" - - id: semver_info - run: echo "clean_semver=1.0.1" >> "$GITHUB_OUTPUT" - - name: "Update Project Version (Maven Only) [${{ steps.java_info.outputs.project_version }} > ${{ steps.semver_info.outputs.clean_semver }}]" - run: echo ok - """); - - assertTextAttributeAtCurrentCaret(WorkflowTextAttributes.VARIABLE_REFERENCE); - } - - public void testWorkflowCallInputDefaultExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: - workflow_call: - inputs: - known-input: - type: string - target: - type: string - default: ${{ inputs.known-input }} - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo ok - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testEnvExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - env: - TOP_LEVEL: top - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo "${{ env.TOP_LEVEL }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testJobEnvMapAliasExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - define: - runs-on: ubuntu-latest - env: &env_vars - NODE_ENV: production - steps: - - run: echo ok - reuse: - runs-on: ubuntu-latest - env: *env_vars - steps: - - run: echo "${{ env.NODE_ENV }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testMatrixExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - steps: - - run: echo "${{ matrix.os }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testStepOutputExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - id: package - run: echo "artifact=dist" >> "$GITHUB_OUTPUT" - - run: echo "${{ steps.package.outputs.artifact }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testNeedsScalarIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - steps: - - run: echo ok - test: - needs: build - runs-on: ubuntu-latest - steps: - - run: echo ok - """); - - assertHighlightedReferenceAtCurrentCaret(); - } - - public void testJobServiceExpressionIsStyledAsReference() { - configureWorkflowProjectFile(""" - name: Styling - on: workflow_dispatch - jobs: - build: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - steps: - - run: echo "${{ job.services.postgres.network }}" - """); - - assertHighlightedReferenceAtCurrentCaret(); - } -} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/GitHubActionCacheTest.java b/src/test/java/com/github/yunabraska/githubworkflow/state/GitHubActionCacheTest.java similarity index 98% rename from src/test/java/com/github/yunabraska/githubworkflow/services/GitHubActionCacheTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/state/GitHubActionCacheTest.java index 1ea9629..9c9a7fc 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/GitHubActionCacheTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/state/GitHubActionCacheTest.java @@ -1,4 +1,6 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.state; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.testFramework.fixtures.BasePlatformTestCase; diff --git a/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowFileIconTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowFileIconTest.java new file mode 100644 index 0000000..f6e7145 --- /dev/null +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowFileIconTest.java @@ -0,0 +1,62 @@ +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowSyntax; + +import com.intellij.icons.AllIcons; + +import javax.swing.Icon; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowFileIconTest extends EditorFeatureTestCase { + + public void testGithubWorkflowUsesGithubIcon() { + configureProjectFile(".github/workflows/build.yml", """ + name: CI + on: push + jobs: {} + """); + + final Icon icon = new WorkflowSyntax.FileIcon().getIcon(myFixture.getFile(), 0); + + assertThat(icon).isSameAs(AllIcons.Vcs.Vendors.Github); + } + + public void testGiteaWorkflowUsesGiteaIcon() { + configureProjectFile(".gitea/workflows/build.yml", """ + name: CI + on: push + jobs: {} + """); + + final Icon icon = new WorkflowSyntax.FileIcon().getIcon(myFixture.getFile(), 0); + + assertThat(icon) + .isNotNull() + .isNotSameAs(AllIcons.Vcs.Vendors.Github); + } + + public void testGiteaIconVariantsArePackaged() { + assertThat(getClass().getClassLoader().getResource("icons/gitea.svg")).isNotNull(); + assertThat(getClass().getClassLoader().getResource("icons/gitea_dark.svg")).isNotNull(); + } + + public void testLightGiteaIconAvoidsWhiteDetails() throws Exception { + final InputStream stream = getClass().getClassLoader().getResourceAsStream("icons/gitea.svg"); + assertThat(stream).isNotNull(); + final String icon = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + + assertThat(icon.toUpperCase()) + .doesNotContain("#FFFFFF") + .doesNotContain("#E8F5E2"); + } + + private void configureProjectFile(final String path, final String text) { + myFixture.addFileToProject(path, text); + myFixture.configureFromTempProjectFile(path); + } +} diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowShowcaseTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowLargeWorkflowTest.java similarity index 78% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowShowcaseTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowLargeWorkflowTest.java index 608782d..dc7c88f 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowShowcaseTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowLargeWorkflowTest.java @@ -1,10 +1,24 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; import com.intellij.psi.PsiFile; import java.util.Map; -public class WorkflowShowcaseTest extends EditorFeatureTestCase { +import static org.assertj.core.api.Assertions.assertThat; + +public class WorkflowLargeWorkflowTest extends EditorFeatureTestCase { + + public void testLargeWorkflowHighlightingCompletesWithinBoundedTime() { + configureWorkflowProjectFile(largeWorkflow()); + + final long started = System.nanoTime(); + myFixture.doHighlighting(); + final long elapsedMillis = (System.nanoTime() - started) / 1_000_000L; + + assertThat(elapsedMillis).isLessThan(10_000L); + } public void testLargeShowcaseWorkflowHighlightsWithoutErrors() { final PsiFile localAction = myFixture.addFileToProject(".github/actions/local/action.yml", """ @@ -67,6 +81,39 @@ public void testLargeShowcaseWorkflowHighlightsWithoutErrors() { myFixture.checkHighlighting(true, false, true); } + private static String largeWorkflow() { + final StringBuilder workflow = new StringBuilder(""" + name: Large Workflow + on: + workflow_call: + inputs: + deploy-target: + type: string + env: + TOP_LEVEL: production + jobs: + """); + for (int job = 0; job < 40; job++) { + workflow.append(" build_").append(job).append(":\n"); + if (job > 0) { + workflow.append(" needs: build_").append(job - 1).append("\n"); + } + workflow.append(""" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + outputs: + artifact: ${{ steps.package.outputs.artifact }} + steps: + - id: package + run: echo "artifact=dist" >> "$GITHUB_OUTPUT" + - run: echo "${{ inputs.deploy-target }} ${{ env.TOP_LEVEL }} ${{ matrix.os }} ${{ steps.package.outputs.artifact }}" + """); + } + return workflow.toString(); + } + private static String showcaseWorkflow() { return """ name: Showcase diff --git a/src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfigTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java similarity index 67% rename from src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfigTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java index b488f24..aaf45b1 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/helper/GitHubWorkflowConfigTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowMetadataTest.java @@ -1,23 +1,23 @@ -package com.github.yunabraska.githubworkflow.helper; +package com.github.yunabraska.githubworkflow.syntax; import org.junit.Test; import java.io.BufferedReader; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Path; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.DEFAULT_VALUE_MAP; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_ENVS; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_GITHUB; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.FIELD_RUNNER; -import static com.github.yunabraska.githubworkflow.helper.GitHubWorkflowConfig.shells; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.DEFAULT_VALUE_MAP; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_ENVS; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_GITHUB; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowContextCatalog.FIELD_RUNNER; import static org.assertj.core.api.Assertions.assertThat; -public class GitHubWorkflowConfigTest { +public class WorkflowMetadataTest { @Test public void githubContextContainsCurrentDocumentedKeys() { @@ -146,15 +146,8 @@ public void runnerDebugDescriptionMatchesDocumentedMeaning() { .doesNotContain("preinstalled tools"); } - @Test - public void shellCompletionDescriptionsAreResolvedOnDemand() { - assertThat(shells()) - .containsKeys("bash", "sh", "pwsh", "powershell", "cmd", "python") - .doesNotContainValue(""); - } - private static List resourceKeys(final String path) throws Exception { - try (InputStream stream = Objects.requireNonNull(GitHubWorkflowConfigTest.class.getResourceAsStream(path)); + try (InputStream stream = Objects.requireNonNull(WorkflowMetadataTest.class.getResourceAsStream(path)); BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { return reader.lines() .filter(line -> !line.isBlank()) @@ -163,4 +156,44 @@ private static List resourceKeys(final String path) throws Exception { .toList(); } } + + @Test + public void detectsWorkflowFilesOnlyUnderGithubWorkflowsDirectory() { + assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", ".github", "workflows", "build.yml"))).isTrue(); + assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", ".github", "workflows", "build.yaml"))).isTrue(); + assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", ".gitea", "workflows", "build.yml"))).isTrue(); + assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", ".github", "not-workflows", "build.yml"))).isFalse(); + assertThat(WorkflowYaml.isWorkflowFile(Path.of("repo", "workflows", "build.yml"))).isFalse(); + } + + @Test + public void invalidVirtualFilePathTextIsRejectedWithoutThrowing() { + assertThat(WorkflowPsi.toPath("<36ba1c43-b8f1-4f54-ace0-cef443d1e8f0>/etc/php/8.1/apache2/php.ini")).isEmpty(); + } + + @Test + public void serializedVirtualFilePathTextIsRejectedWithoutThrowing() { + assertThat(WorkflowPsi.toPath("{\"sessionId\":\"2cc03ab1-37d6-47cd-980d-1bb135073b4d\"}")).isEmpty(); + } + + @Test + public void detectsActionMetadataFilesByName() { + assertThat(WorkflowYaml.isActionFile(Path.of("repo", "action.yml"))).isTrue(); + assertThat(WorkflowYaml.isActionFile(Path.of("repo", "nested", "ACTION.YAML"))).isTrue(); + assertThat(WorkflowYaml.isActionFile(Path.of("repo", "workflow.yml"))).isFalse(); + } + + @Test + public void detectsSchemaTargetFiles() { + assertThat(WorkflowYaml.isDependabotFile(Path.of("repo", ".github", "dependabot.yml"))).isTrue(); + assertThat(WorkflowYaml.isFoundingFile(Path.of("repo", ".github", "FUNDING.yml"))).isTrue(); + assertThat(WorkflowYaml.isIssueForms(Path.of("repo", ".github", "ISSUE_TEMPLATE", "bug.yml"))).isTrue(); + assertThat(WorkflowYaml.isDiscussionFile(Path.of("repo", ".github", "DISCUSSION_TEMPLATE", "question.yaml"))).isTrue(); + } + + @Test + public void rejectsIssueConfigOutsideIssueTemplateDirectory() { + assertThat(WorkflowYaml.isIssueConfigFile(Path.of("repo", ".github", "ISSUE_TEMPLATE", "config.yml"))).isTrue(); + assertThat(WorkflowYaml.isIssueConfigFile(Path.of("repo", ".github", "workflow-templates", "config.yml"))).isFalse(); + } } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowQuickFixTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowQuickFixesTest.java similarity index 97% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowQuickFixTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowQuickFixesTest.java index c15ffc1..cbe74fd 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowQuickFixTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowQuickFixesTest.java @@ -1,4 +1,10 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; + +import com.github.yunabraska.githubworkflow.i18n.GitHubWorkflowBundle; import com.intellij.codeInsight.intention.IntentionAction; import com.intellij.openapi.util.Iconable; @@ -10,7 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; -public class WorkflowQuickFixTest extends EditorFeatureTestCase { +public class WorkflowQuickFixesTest extends EditorFeatureTestCase { public void testUnknownActionInputProvidesDeleteQuickFix() { seedRemoteAction("owner/tool@v1", Map.of("known-input", "Known input"), Map.of()); @@ -29,7 +35,7 @@ public void testUnknownActionInputProvidesDeleteQuickFix() { } public void testInspectionQuickFixTextsUseConfiguredPluginLanguage() { - final PluginSettings settings = PluginSettings.getInstance(); + final GitHubWorkflowBundle.Settings settings = GitHubWorkflowBundle.Settings.getInstance(); final String previousLanguage = settings.languageTag(); try { settings.languageTag("de"); diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowReferenceTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferencesTest.java similarity index 97% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowReferenceTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferencesTest.java index 1b78219..8365307 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowReferenceTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowReferencesTest.java @@ -1,4 +1,14 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.syntax.WorkflowReferences; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.openapi.paths.WebReference; @@ -11,10 +21,10 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; -import static com.github.yunabraska.githubworkflow.services.ReferenceContributor.ACTION_KEY; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; +import static com.github.yunabraska.githubworkflow.syntax.WorkflowReferences.ACTION_KEY; +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.getActionCache; -public class WorkflowReferenceTest extends EditorFeatureTestCase { +public class WorkflowReferencesTest extends EditorFeatureTestCase { public void testLocalActionReferenceResolvesToActionFile() { final PsiFile actionFile = myFixture.addFileToProject(".github/actions/local/action.yml", """ @@ -176,7 +186,7 @@ public void testConfiguredGithubEnterpriseRemoteActionReferenceKeepsServerUrl() - run: echo ok shell: sh """); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server("Fake Enterprise", + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server("Fake Enterprise", server.webUrl(), server.apiUrl("/api/v3"), "", diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java similarity index 97% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java index 7ee94f9..c22e756 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowCompletionTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowSyntaxCompletionTest.java @@ -1,4 +1,14 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.entry.WorkflowCompletion; + +import com.github.yunabraska.githubworkflow.test.FakeRemoteServer; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.github.yunabraska.githubworkflow.model.GitHubAction; import com.intellij.codeInsight.editorActions.TypedHandlerDelegate; @@ -11,10 +21,10 @@ import java.util.Map; import java.util.stream.Collectors; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.getActionCache; import static org.assertj.core.api.Assertions.assertThat; -public class WorkflowCompletionTest extends EditorFeatureTestCase { +public class WorkflowSyntaxCompletionTest extends EditorFeatureTestCase { public void testAutoPopupTriggersAfterYamlNewLine() { configureWorkflow(""" @@ -27,7 +37,7 @@ public void testAutoPopupTriggersAfterYamlNewLine() { } public void testLineBeforeCaretHandlesInjectedZeroOffset() { - assertThat(CodeCompletion.lineBeforeCaret(""" + assertThat(WorkflowCompletion.lineBeforeCaret(""" name: Completion on: workflow_dispatch @@ -58,7 +68,7 @@ public void testAutoPopupTypedHandlerSchedulesYamlNewLine() { on: """); - final WorkflowAutoPopupTypedHandler handler = new WorkflowAutoPopupTypedHandler(); + final WorkflowCompletion.TypedAutoPopup handler = new WorkflowCompletion.TypedAutoPopup(); assertThat(handler.checkAutoPopup('\n', getProject(), myFixture.getEditor(), myFixture.getFile())) .isEqualTo(TypedHandlerDelegate.Result.CONTINUE); @@ -78,7 +88,7 @@ public void testEnterHandlerTriggersAfterYamlMappingKey() { - run: echo ok """); - assertThat(WorkflowAutoPopupEnterHandler.shouldAutoPopupAfterEnter(myFixture.getEditor(), myFixture.getFile())) + assertThat(WorkflowCompletion.EnterAutoPopup.shouldAutoPopupAfterEnter(myFixture.getEditor(), myFixture.getFile())) .isTrue(); } @@ -94,7 +104,7 @@ public void testEnterHandlerIgnoresPlainYamlValueLine() { - run: echo ok """); - assertThat(WorkflowAutoPopupEnterHandler.shouldAutoPopupAfterEnter(myFixture.getEditor(), myFixture.getFile())) + assertThat(WorkflowCompletion.EnterAutoPopup.shouldAutoPopupAfterEnter(myFixture.getEditor(), myFixture.getFile())) .isFalse(); } @@ -105,7 +115,7 @@ public void testWorkflowCompletionConfidenceKeepsAutoPopupAvailable() { """); - assertThat(new WorkflowCompletionConfidence().shouldSkipAutopopup( + assertThat(new WorkflowCompletion.Confidence().shouldSkipAutopopup( myFixture.getEditor(), myFixture.getFile().findElementAt(myFixture.getCaretOffset()), myFixture.getFile(), @@ -177,7 +187,7 @@ public void testRootCompletionSuggestsAvailableContexts() { } private boolean invokeAutoPopup(final char typeChar) { - return WorkflowAutoPopupTypedHandler.shouldAutoPopup(typeChar, myFixture.getEditor(), myFixture.getFile()); + return WorkflowCompletion.TypedAutoPopup.shouldAutoPopup(typeChar, myFixture.getEditor(), myFixture.getFile()); } public void testGithubCompletionSuggestsRefName() { @@ -415,7 +425,7 @@ public void testInputsCompletionUsesWorkflowCallInputs() { """)).contains("deploy-target"); } - public void testInputsCompletionUsesWorkflowDispatchInputs() { + public void testInputsCompletionUsesDispatchInputs() { assertThat(completeWorkflow(""" name: Completion on: @@ -1655,7 +1665,7 @@ public void testUsesCompletionDiscoversRemoteCallableTargetsBeforeResolution() t "checkout", "Checkout repository", "setup-java", "Set up Java" )); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server( + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server( "Fake Enterprise", server.webUrl(), server.apiUrl("/api/v3"), @@ -1697,7 +1707,7 @@ public void testUsesRefCompletionSuggestsKnownRemoteRefsFromCache() { public void testUsesRefCompletionDiscoversLatestRemoteRefsBeforeActionIsResolved() throws Exception { try (FakeRemoteServer server = new FakeRemoteServer()) { server.setTags("acme", "tool", List.of("v10", "v9", "v8", "v7", "v6", "v5", "v4", "v3", "v2", "v1", "v0")); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server( + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server( "Fake Enterprise", server.webUrl(), server.apiUrl("/api/v3"), @@ -1767,7 +1777,7 @@ public void testUsesRefCompletionSuggestsRefsResolvedFromConfiguredServer() thro """); server.setBranches("acme", "tool", List.of("main")); server.setTags("acme", "tool", List.of("v1")); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server("Fake", + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server("Fake", server.webUrl(), server.apiUrl("/api/v3"), "", @@ -1801,7 +1811,7 @@ public void testAbsoluteGithubServerUrlCompletionSuggestsRefsResolvedFromConfigu """); server.setBranches("acme", "tool", List.of("main")); server.setTags("acme", "tool", List.of("v2")); - RemoteServerSettings.getInstance().setCustomServers(List.of(new RemoteServerSettings.Server("Fake Enterprise", + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of(new RemoteActionProviders.Server("Fake Enterprise", server.webUrl(), server.apiUrl("/api/v3"), "", @@ -1969,7 +1979,7 @@ private Map completeWorkflowTypeTexts(final String text) { return java.util.Arrays.stream(elements) .collect(Collectors.toMap( LookupElement::getLookupString, - WorkflowCompletionTest::typeText, + WorkflowSyntaxCompletionTest::typeText, (left, right) -> left )); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowHighlightingTest.java b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java similarity index 99% rename from src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowHighlightingTest.java rename to src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java index 04d9c07..5c34d30 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/WorkflowHighlightingTest.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/syntax/WorkflowValidationTest.java @@ -1,8 +1,10 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.syntax; + +import com.github.yunabraska.githubworkflow.test.EditorFeatureTestCase; import java.util.Map; -public class WorkflowHighlightingTest extends EditorFeatureTestCase { +public class WorkflowValidationTest extends EditorFeatureTestCase { public void testUnknownTopLevelWorkflowKeyIsHighlighted() { assertWorkflowHighlights(""" diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/EditorFeatureTestCase.java b/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java similarity index 95% rename from src/test/java/com/github/yunabraska/githubworkflow/services/EditorFeatureTestCase.java rename to src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java index f1566fd..d243a65 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/EditorFeatureTestCase.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/test/EditorFeatureTestCase.java @@ -1,4 +1,8 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.test; + +import com.github.yunabraska.githubworkflow.git.RemoteActionProviders; + +import com.github.yunabraska.githubworkflow.state.GitHubActionCache; import com.intellij.codeInsight.intention.IntentionAction; import com.github.yunabraska.githubworkflow.model.GitHubAction; @@ -25,15 +29,15 @@ import java.util.Map; import java.util.stream.Stream; -import static com.github.yunabraska.githubworkflow.services.GitHubActionCache.getActionCache; +import static com.github.yunabraska.githubworkflow.state.GitHubActionCache.getActionCache; -abstract class EditorFeatureTestCase extends BasePlatformTestCase { +public abstract class EditorFeatureTestCase extends BasePlatformTestCase { @Override protected void setUp() throws Exception { super.setUp(); getActionCache().getState().actions.clear(); - RemoteServerSettings.getInstance().setCustomServers(List.of()); + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of()); ((CodeInsightTestFixtureImpl) myFixture).canChangeDocumentDuringHighlighting(true); } @@ -41,7 +45,7 @@ protected void setUp() throws Exception { protected void tearDown() throws Exception { try { getActionCache().getState().actions.clear(); - RemoteServerSettings.getInstance().setCustomServers(List.of()); + RemoteActionProviders.Settings.getInstance().setCustomServers(List.of()); } finally { super.tearDown(); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/FakeRemoteServer.java b/src/test/java/com/github/yunabraska/githubworkflow/test/FakeRemoteServer.java similarity index 89% rename from src/test/java/com/github/yunabraska/githubworkflow/services/FakeRemoteServer.java rename to src/test/java/com/github/yunabraska/githubworkflow/test/FakeRemoteServer.java index 73ffd19..dc5b011 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/FakeRemoteServer.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/test/FakeRemoteServer.java @@ -1,4 +1,4 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.test; import com.sun.net.httpserver.HttpServer; @@ -13,7 +13,7 @@ import java.util.List; import java.util.Map; -final class FakeRemoteServer implements AutoCloseable { +public class FakeRemoteServer implements AutoCloseable { private final HttpServer server; private final Map contents = new HashMap<>(); @@ -22,7 +22,7 @@ final class FakeRemoteServer implements AutoCloseable { private final Map> repositories = new HashMap<>(); private final List requests = new ArrayList<>(); - FakeRemoteServer() throws IOException { + public FakeRemoteServer() throws IOException { server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); server.createContext("/", exchange -> { final URI uri = exchange.getRequestURI(); @@ -38,31 +38,31 @@ final class FakeRemoteServer implements AutoCloseable { server.start(); } - String webUrl() { + public String webUrl() { return "http://" + server.getAddress().getHostString() + ":" + server.getAddress().getPort(); } - String apiUrl(final String prefix) { + public String apiUrl(final String prefix) { return webUrl() + prefix; } - List requests() { + public List requests() { return List.copyOf(requests); } - void addContent(final String owner, final String repo, final String path, final String ref, final String content) { + public void addContent(final String owner, final String repo, final String path, final String ref, final String content) { contents.put(key(owner, repo, path, ref), content); } - void setBranches(final String owner, final String repo, final List values) { + public void setBranches(final String owner, final String repo, final List values) { branches.put(owner + "/" + repo, values); } - void setTags(final String owner, final String repo, final List values) { + public void setTags(final String owner, final String repo, final List values) { tags.put(owner + "/" + repo, values); } - void setRepositories(final String owner, final Map values) { + public void setRepositories(final String owner, final Map values) { repositories.put(owner, values); } diff --git a/src/test/java/com/github/yunabraska/githubworkflow/services/PluginExecutionPolicy.java b/src/test/java/com/github/yunabraska/githubworkflow/test/PluginExecutionPolicy.java similarity index 90% rename from src/test/java/com/github/yunabraska/githubworkflow/services/PluginExecutionPolicy.java rename to src/test/java/com/github/yunabraska/githubworkflow/test/PluginExecutionPolicy.java index 891d51e..a3bd95a 100644 --- a/src/test/java/com/github/yunabraska/githubworkflow/services/PluginExecutionPolicy.java +++ b/src/test/java/com/github/yunabraska/githubworkflow/test/PluginExecutionPolicy.java @@ -1,4 +1,4 @@ -package com.github.yunabraska.githubworkflow.services; +package com.github.yunabraska.githubworkflow.test; import com.intellij.testFramework.fixtures.IdeaTestExecutionPolicy;